Display a real progress bar for server installs

This change replaces the fake progress bar with a real one, reflecting
incremental installation progress.  It also updates the text to avoid
claiming that installation takes less than two minutes.  (It takes about
five minutes on GCP.)

Additionally, this change converts outline-server-progress-step.js to
Typescript.
This commit is contained in:
Ben Schwartz 2021-07-09 16:11:29 -04:00
parent a8a10c2c71
commit b4e5981aa5
12 changed files with 226 additions and 182 deletions

View file

@ -117,6 +117,8 @@ function cloud::add_encoded_kv_tag() {
cloud::add_tag "kv:${key}:${value}"
}
echo "true" | cloud::add_encoded_kv_tag "outline"
log_for_sentry "Starting install"
# DigitalOcean's docker image comes with ufw enabled by default, disable so when

View file

@ -29,7 +29,7 @@ export SHADOWBOX_DIR="${SHADOWBOX_DIR:-${HOME:-/root}/shadowbox}"
mkdir -p "${SHADOWBOX_DIR}"
# Save output for debugging
exec &> "${SHADOWBOX_DIR}/install-shadowbox-output"
true > "${SHADOWBOX_DIR}/install-shadowbox-output"
function cloud::public_ip() {
curl curl -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip"
@ -66,6 +66,8 @@ function cloud::set_guest_attribute() {
curl -H "Metadata-Flavor: Google" -X PUT -d "${label_value}" "${SET_GUEST_ATTRIBUTE_URL}"
}
cloud::set_guest_attribute "outline" "true"
# Enable BBR.
# Recent DigitalOcean one-click images are based on Ubuntu 18 and have kernel 4.15+.
log_for_sentry "Enabling BBR"

View file

@ -243,7 +243,7 @@
"setup-do-cost": "Only US$5 a month",
"setup-do-create": "Create a new server with your DigitalOcean account for an additional US$5/30 days for 1 TB of data transfer.",
"setup-do-data": "1 TB data transfer allowance",
"setup-do-description": "This could take up to two minutes. You can destroy this server at anytime.",
"setup-do-description": "This could take several minutes. You can destroy this server at any time.",
"setup-do-easiest": "Easiest setup process",
"setup-do-title": "Setting up Outline.",
"setup-firewall-instructions": "Firewall instructions",

View file

@ -1124,7 +1124,7 @@
"description": "This string appears in the server setup view as an item of a list describing Outline's features. Refers to the monthly amount of transfer data offered by a cloud server provider. TB is an abbreviation for terabyte and should not be translated."
},
"setup_do_description": {
"message": "This could take up to two minutes. You can destroy this server at anytime.",
"message": "This could take several minutes. You can destroy this server at any time.",
"description": "This string appears in the server setup view as a sub-header. Displayed when a server is being created along a progress bar."
},
"setup_do_easiest": {

View file

@ -109,6 +109,8 @@ export interface ManualServer extends Server {
export interface ManagedServer extends Server {
// Returns a promise that fulfills once installation is complete.
waitOnInstall(): Promise<void>;
// Enables notifications related to installation progress.
setProgressListener(listener: (progress: number) => void): void;
// Returns server host object.
getHost(): ManagedServerHost;
// Returns true when installation is complete.

View file

@ -798,10 +798,13 @@ export class App {
};
}
private async setServerProgressView(server: server.Server): Promise<void> {
private async setServerProgressView(server: server.ManagedServer): Promise<void> {
const view = await this.appRoot.getServerView(server.getId());
view.serverName = this.makeDisplayName(server);
view.selectedPage = 'progressView';
server.setProgressListener(progress => {
view.installProgress = progress;
});
}
private showMetricsOptInWhenNeeded(selectedServer: server.Server, serverView: ServerView) {

View file

@ -16,9 +16,8 @@ import {EventEmitter} from 'eventemitter3';
import {DigitalOceanSession, DropletInfo} from '../cloud/digitalocean_api';
import * as errors from '../infrastructure/errors';
import {asciiToHex, hexToString} from '../infrastructure/hex_encoding';
import {hexToString} from '../infrastructure/hex_encoding';
import { Region } from '../model/digitalocean';
import {CloudLocation} from '../model/location';
import * as server from '../model/server';
import {ShadowboxServer} from './shadowbox_server';
@ -38,19 +37,16 @@ const DEPRECATED_API_PORT_TAG = 'apiport';
// The tag key for the manager API url prefix.
const DEPRECATED_API_PREFIX_TAG = 'apiprefix';
function makeKeyValueTagPrefix(key: string) {
return makeKeyValueTag(key, '');
}
function makeKeyValueTag(key: string, value: string) {
return [KEY_VALUE_TAG, key, asciiToHex(value)].join(':');
}
// Possible install states for DigitaloceanServer.
enum InstallState {
// Unknown state - server may still be installing.
UNKNOWN = 0,
// Server status is "active"
CREATED,
// Userspace is running (detected by the presence of tags)
BOOTED,
// The server has generated its management service certificate.
HAS_CERTIFICATE,
// Server is running and has the API URL and certificate fingerprint set.
SUCCESS,
// Server is in an error state.
@ -62,6 +58,7 @@ enum InstallState {
export class DigitalOceanServer extends ShadowboxServer implements server.ManagedServer {
private eventQueue = new EventEmitter();
private installState: InstallState = InstallState.UNKNOWN;
private listener: (progress: number) => void;
constructor(
id: string, private digitalOcean: DigitalOceanSession, private dropletInfo: DropletInfo) {
@ -78,11 +75,11 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
// Poll this.installState for changes. This can poll quickly as it
// will not make any network requests.
const intervalId = setInterval(() => {
if (this.installState === InstallState.UNKNOWN) {
// installState not known, wait until next retry.
if (!this.isInstallStateFinal()) {
// Final installState not known, wait until next retry.
return;
}
// State is now known, so we can stop checking.
// Final state is now known, so we can stop checking.
clearInterval(intervalId);
if (this.installState === InstallState.SUCCESS) {
fulfill();
@ -104,12 +101,13 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
// Synchronous function for updating the installState, which doesn't
// refresh droplet info.
const updateInstallState = (): void => {
if (this.installState !== InstallState.UNKNOWN) {
// State is already known, so it cannot be changed.
if (this.isInstallStateFinal()) {
// Final state is already known, so it cannot be changed.
return;
}
if (this.getTagValue(INSTALL_ERROR_TAG)) {
console.error(`error tag: ${this.getTagValue(INSTALL_ERROR_TAG)}`);
const tagMap = this.getTagMap();
if (tagMap[INSTALL_ERROR_TAG]) {
console.error(`error tag: ${tagMap[INSTALL_ERROR_TAG]}`);
this.setInstallState(InstallState.ERROR);
} else if (Date.now() - startTimestamp >= TIMEOUT_MS) {
console.error('hit timeout while waiting for installation');
@ -119,22 +117,28 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
// installed the server and can now make API calls.
console.info('digitalocean_server: Successfully found API and cert tags');
this.setInstallState(InstallState.SUCCESS);
} else if (tagMap[CERTIFICATE_FINGERPRINT_TAG]) {
this.setInstallState(InstallState.HAS_CERTIFICATE);
} else if (Object.keys(tagMap).length > 0) {
this.setInstallState(InstallState.BOOTED);
} else if (this.dropletInfo?.status === 'active') {
this.setInstallState(InstallState.CREATED);
}
};
// Attempt to set the install state immediately, based on the initial
// droplet info, to possibly save on a refresh API call.
updateInstallState();
if (this.installState !== InstallState.UNKNOWN) {
if (this.isInstallStateFinal()) {
return;
}
// Periodically refresh the droplet info then try to update the install
// state again.
const intervalId = setInterval(async () => {
// Check if install state is already known, so we don't make an unnecessary
// request to fetch droplet info.
if (this.installState !== InstallState.UNKNOWN) {
// Check if the final install state has been reached, so we don't make an
// unnecessary request to fetch droplet info.
if (this.isInstallStateFinal()) {
clearInterval(intervalId);
return;
}
@ -147,9 +151,9 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
return;
}
updateInstallState();
// Immediately clear the interval if the installState is known to prevent
// race conditions due to setInterval firing async.
if (this.installState !== InstallState.UNKNOWN) {
// Immediately clear the interval if the final install state has been
// reached to prevent race conditions due to setInterval firing async.
if (this.isInstallStateFinal()) {
clearInterval(intervalId);
return;
}
@ -158,16 +162,42 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
}, 3000);
}
setProgressListener(listener: (progress: number) => void): void {
this.listener = listener;
listener(this.installProgress());
}
private setInstallState(installState: InstallState) {
if (this.installState !== InstallState.UNKNOWN) {
// Cannot change the install state once set.
return;
}
if (installState === InstallState.UNKNOWN) {
if (this.isInstallStateFinal()) {
// The install state is final and cannot be changed.
return;
}
this.installState = installState;
this.setInstallCompleted();
if (this.installState === InstallState.SUCCESS) {
this.setInstallCompleted();
}
if (this.listener) {
this.listener(this.installProgress());
}
}
private installProgress(): number {
// Values are based on observed installation timing.
// Installation typically takes 90 seconds in total.
switch (this.installState) {
case InstallState.UNKNOWN: return 0.1;
case InstallState.CREATED: return 0.5;
case InstallState.BOOTED: return 0.55;
case InstallState.HAS_CERTIFICATE: return 0.6;
case InstallState.SUCCESS: return 1.0;
default: return 0;
}
}
private isInstallStateFinal(): boolean {
return this.installState === InstallState.SUCCESS ||
this.installState === InstallState.ERROR ||
this.installState === InstallState.DELETED;
}
// Returns true on success, else false.
@ -199,21 +229,23 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
}
}
// Gets the value for the given key, stored in the DigitalOcean tags.
private getTagValue(key: string): string {
const tagPrefix = makeKeyValueTagPrefix(key);
// Gets the key-value map stored in the DigitalOcean tags.
private getTagMap(): {[key: string]: string} {
const ret: {[key: string]: string} = {};
const tagPrefix = KEY_VALUE_TAG + ':';
for (const tag of this.dropletInfo.tags) {
if (!startsWithCaseInsensitive(tag, tagPrefix)) {
continue;
}
const encodedData = tag.slice(tagPrefix.length);
const keyValuePair = tag.slice(tagPrefix.length);
const [key, hexValue] = keyValuePair.split(':', 2);
try {
return hexToString(encodedData);
ret[key.toLowerCase()] = hexToString(hexValue);
} catch (e) {
console.error('error decoding hex string');
return null;
}
}
return ret;
}
// Returns the public ipv4 address of this server.
@ -228,11 +260,12 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
// Gets the address for the user management api, throws an error if unavailable.
private getManagementApiAddress(): string {
let apiAddress = this.getTagValue(API_URL_TAG);
const tagMap = this.getTagMap();
let apiAddress = tagMap[API_URL_TAG];
// Check the old tags for backward-compatibility.
// TODO(fortuna): Delete this before we release v1.0
if (!apiAddress) {
const portNumber = this.getTagValue(DEPRECATED_API_PORT_TAG);
const portNumber = tagMap[DEPRECATED_API_PORT_TAG];
if (!portNumber) {
throw new Error('Could not get API port number');
}
@ -240,7 +273,7 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
throw new Error('API hostname not set');
}
apiAddress = `https://${this.ipv4Address()}:${portNumber}/`;
const apiPrefix = this.getTagValue(DEPRECATED_API_PREFIX_TAG);
const apiPrefix = tagMap[DEPRECATED_API_PREFIX_TAG];
if (apiPrefix) {
apiAddress += apiPrefix + '/';
}
@ -254,7 +287,7 @@ export class DigitalOceanServer extends ShadowboxServer implements server.Manage
// Gets the certificate fingerprint in base64 format, throws an error if
// unavailable.
private getCertificateFingerprint(): string {
const fingerprint = this.getTagValue(CERTIFICATE_FINGERPRINT_TAG);
const fingerprint = this.getTagMap()[CERTIFICATE_FINGERPRINT_TAG];
if (fingerprint) {
return btoa(fingerprint);
} else {

View file

@ -22,8 +22,16 @@ import {DataAmount, ManagedServerHost, MonetaryCost} from '../model/server';
import {ShadowboxServer} from './shadowbox_server';
enum InstallState {
// Unknown state - server may still be installing.
// Unknown state - server request may still be pending.
UNKNOWN = 0,
// The instance has been created.
INSTANCE_CREATED,
// The static IP has been allocated.
IP_ALLOCATED,
// The system has booted (detected by the creation of guest tags)
BOOTED,
// The server has generated its management service certificate.
HAS_CERTIFICATE,
// Server is running and has the API URL and certificate fingerprint set.
SUCCESS,
// Server is in an error state.
@ -40,6 +48,7 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer {
private readonly instanceReadiness: Promise<void>;
private readonly gcpHost: GcpHost;
private installState: InstallState = InstallState.UNKNOWN;
private listener: (progress: number) => void = null;
constructor(
id: string,
@ -51,9 +60,11 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer {
// Optimization: start the check for a static IP immediately.
const hasStaticIp: Promise<boolean> = this.hasStaticIp();
this.instanceReadiness = instanceCreation.then(async () => {
this.setInstallState(InstallState.INSTANCE_CREATED);
if (!await hasStaticIp) {
await this.promoteEphemeralIp();
}
this.setInstallState(InstallState.IP_ALLOCATED);
}).catch((e) => {
this.setInstallState(InstallState.ERROR);
throw e;
@ -104,23 +115,29 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer {
}
isInstallCompleted(): boolean {
return this.installState !== InstallState.UNKNOWN;
return this.installState === InstallState.SUCCESS ||
this.installState === InstallState.ERROR ||
this.installState === InstallState.DELETED;
}
async waitOnInstall(): Promise<void> {
await this.instanceReadiness; // Throws if instance preparation fails.
while (this.installState === InstallState.UNKNOWN) {
while (!this.isInstallCompleted()) {
const outlineGuestAttributes = await this.getOutlineGuestAttributes();
if (outlineGuestAttributes.has('apiUrl') && outlineGuestAttributes.has('certSha256')) {
const certSha256 = outlineGuestAttributes.get('certSha256');
const apiUrl = outlineGuestAttributes.get('apiUrl');
trustCertificate(certSha256);
this.setManagementApiUrl(apiUrl);
this.installState = InstallState.SUCCESS;
this.setInstallState(InstallState.SUCCESS);
break;
} else if (outlineGuestAttributes.has('install-error')) {
this.installState = InstallState.ERROR;
this.setInstallState(InstallState.ERROR);
break;
} else if (outlineGuestAttributes.has('certSha256')) {
this.setInstallState(InstallState.HAS_CERTIFICATE);
} else if (outlineGuestAttributes.has('outline')) {
this.setInstallState(InstallState.BOOTED);
}
await sleep(GcpServer.GUEST_ATTRIBUTES_POLLING_INTERVAL_MS);
@ -134,6 +151,25 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer {
}
}
setProgressListener(listener: (progress: number) => void): void {
this.listener = listener;
listener(this.installProgress());
}
private installProgress(): number {
// Values are based on observed installation timing.
// Installation typically takes ~5 minutes in total.
switch (this.installState) {
case InstallState.UNKNOWN: return 0.005;
case InstallState.INSTANCE_CREATED: return 0.03;
case InstallState.IP_ALLOCATED: return 0.04;
case InstallState.BOOTED: return 0.2;
case InstallState.HAS_CERTIFICATE: return 0.8;
case InstallState.SUCCESS: return 1.0;
default: return 0;
}
}
private async getOutlineGuestAttributes(): Promise<Map<string, string>> {
const result = new Map<string, string>();
const guestAttributes =
@ -147,6 +183,9 @@ export class GcpServer extends ShadowboxServer implements server.ManagedServer {
setInstallState(newState: InstallState): void {
this.installState = newState;
if (this.listener) {
this.listener(this.installProgress());
}
}
}

View file

@ -222,6 +222,9 @@ export class FakeManagedServer extends FakeServer implements server.ManagedServe
// shadowbox install time.
return new Promise<void>((fulfill, reject) => {});
}
setProgressListener(listener: (progress: number) => void): void {
listener(0.5);
}
getHost() {
return {
getMonthlyOutboundTransferLimit: () => ({terabytes: 1}),

View file

@ -1,121 +0,0 @@
/*
Copyright 2018 The Outline Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import '@polymer/polymer/polymer-legacy.js';
import '@polymer/paper-progress/paper-progress.js';
import '@polymer/paper-button/paper-button.js';
import './cloud-install-styles.js';
import './outline-progress-spinner.js';
import './outline-step-view.js';
import './style.css';
import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
Polymer({
_template: html`
<style include="cloud-install-styles"></style>
<style>
:host {
text-align: center;
}
.card {
margin-top: 72px;
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.2);
border-radius: 2px;
color: var(--light-gray);
background: var(--background-contrast-color);
display: flex;
flex-direction: column;
align-items: center;
}
.servername {
margin: 24px 0 72px 0;
text-align: center;
}
.card p {
font-size: 14px;
color: var(--light-gray);
}
outline-progress-spinner {
margin-top: 72px;
}
paper-button {
width: 100%;
border: 1px solid var(--light-gray);
border-radius: 2px;
color: var(--light-gray);
}
</style>
<outline-step-view display-action="">
<span slot="step-title">[[localize('setup-do-title')]]</span>
<span slot="step-description">[[localize('setup-do-description')]]</span>
<span slot="step-action">
<paper-button id="cancelButton" hidden\$="{{!showCancelButton}}" on-tap="handleCancelTapped">[[localize('cancel')]]</paper-button>
</span>
<div class="card">
<outline-progress-spinner></outline-progress-spinner>
<div class="servername">
<p>{{serverName}}</p>
</div>
<paper-progress id="bar" class="transiting"></paper-progress>
</div>
</outline-step-view>
`,
is: 'outline-server-progress-step',
properties: {
serverName: String,
showCancelButton: Boolean,
updateIntervalId: Number,
localize: Function,
},
startAnimation: function() {
if (this.updateIntervalId) {
this.stop();
}
this.$.bar.value = 0;
const expected = 90; // seconds
const uncertainty = 30; // seconds
const startTime = Date.now() / 1000;
// For smoothness, this should match the CSS transition duration.
const updateInterval = 1.0; // seconds.
this.updateIntervalId = setInterval(() => {
const elapsed = Date.now() / 1000 - startTime;
// This heuristic happens to correspond to a Weibull distribution.
const k = expected / uncertainty;
const lambda = expected / Math.pow(Math.log(2), 1 / k);
const conditionalMedian =
lambda * Math.pow(Math.pow(elapsed / lambda, k) + Math.log(2), 1 / k);
this.$.bar.value = 100 * (elapsed / conditionalMedian);
}, updateInterval * 1000);
},
stopAnimation: function() {
if (!this.updateIntervalId) {
return;
}
clearInterval(this.updateIntervalId);
this.updateIntervalId = null;
},
handleCancelTapped: function() {
this.fire('CancelServerCreationRequested');
}
});

View file

@ -0,0 +1,87 @@
/*
Copyright 2018 The Outline Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import '@polymer/paper-progress/paper-progress';
import '@polymer/paper-button/paper-button';
import './outline-progress-spinner';
import './outline-step-view';
import {css, customElement, html, LitElement, property} from 'lit-element';
import {COMMON_STYLES} from './cloud-install-styles.js';
@customElement('outline-server-progress-step')
export class OutlineServerProgressStep extends LitElement {
@property({type: String}) serverName: string;
@property({type: Number}) progress = 0;
@property({type: Function}) localize: Function;
static get styles() {
return [COMMON_STYLES, css`
:host {
text-align: center;
}
.card {
margin-top: 72px;
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.2);
border-radius: 2px;
color: var(--light-gray);
background: var(--background-contrast-color);
display: flex;
flex-direction: column;
align-items: center;
}
.servername {
margin: 24px 0 72px 0;
text-align: center;
}
.card p {
font-size: 14px;
color: var(--light-gray);
}
outline-progress-spinner {
margin-top: 72px;
}
paper-button {
width: 100%;
border: 1px solid var(--light-gray);
border-radius: 2px;
color: var(--light-gray);
}
`];
}
render() {
return html`
<outline-step-view display-action="">
<span slot="step-title">${this.localize('setup-do-title')}</span>
<span slot="step-description">${this.localize('setup-do-description')}</span>
<span slot="step-action">
<paper-button id="cancelButton" on-tap="${this.handleCancelTapped}">
${this.localize('cancel')}
</paper-button>
</span>
<div class="card">
<outline-progress-spinner></outline-progress-spinner>
<div class="servername">
<p>${this.serverName}</p>
</div>
<paper-progress id="bar" class="transiting" value="${100 * this.progress}"></paper-progress>
</div>
</outline-step-view>`;
}
private handleCancelTapped() {
this.dispatchEvent(new CustomEvent('CancelServerCreationRequested'));
}
}

View file

@ -29,7 +29,7 @@ import './cloud-install-styles.js';
import './outline-iconset.js';
import './outline-help-bubble.js';
import './outline-metrics-option-dialog.js';
import './outline-server-progress-step.js';
import './outline-server-progress-step';
import './outline-server-settings.js';
import './outline-share-dialog.js';
import './outline-sort-span.js';
@ -403,8 +403,8 @@ export class ServerView extends DirMixin(PolymerElement) {
</style>
<div class="container">
<iron-pages id="pages" attr-for-selected="id" selected="[[selectedPage]]" on-changed="_selectedPageChanged">
<outline-server-progress-step id="progressView" server-name="[[serverName]]" localize="[[localize]]"></outline-server-progress-step>
<iron-pages id="pages" attr-for-selected="id" selected="[[selectedPage]]">
<outline-server-progress-step id="progressView" server-name="[[serverName]]" localize="[[localize]]" progress="[[installProgress]]"></outline-server-progress-step>
<div id="unreachableView">${this.unreachableViewTemplate}</div>
<div id="managementView">${this.managementViewTemplate}</div>
</iron-pages>
@ -635,6 +635,7 @@ export class ServerView extends DirMixin(PolymerElement) {
supportsDefaultDataLimit: Boolean,
showFeatureMetricsDisclaimer: Boolean,
isServerManaged: Boolean,
installProgress: Number,
isServerReachable: Boolean,
retryDisplayingServer: Function,
myConnection: Object,
@ -685,6 +686,7 @@ export class ServerView extends DirMixin(PolymerElement) {
this.supportsDefaultDataLimit = false;
this.showFeatureMetricsDisclaimer = false;
this.isServerManaged = false;
this.installProgress = 0;
this.isServerReachable = false;
/**
* Callback for retrying to display an unreachable server.
@ -973,14 +975,6 @@ export class ServerView extends DirMixin(PolymerElement) {
}
}
_selectedPageChanged() {
if (this.selectedPage === 'progressView') {
this.$.progressView.startAnimation();
} else {
this.$.progressView.stopAnimation();
}
}
_selectedTabChanged() {
if (this.selectedTab === 'settings') {
this._closeAddAccessKeyHelpBubble();