diff --git a/package.json b/package.json index 520c5f46b..5c966d0ff 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "husky": "^1.3.1", "jasmine": "^3.5.0", "tslint": "^5.9.1", - "typescript": "^3.8.3" + "typescript": "^4" }, "engines": { "yarn": ">=1.0", diff --git a/src/server_manager/package.json b/src/server_manager/package.json index 1d45a7ffb..825e40a29 100644 --- a/src/server_manager/package.json +++ b/src/server_manager/package.json @@ -41,7 +41,6 @@ "@sentry/electron": "^1.3.0", "@webcomponents/webcomponentsjs": "^2.0.0", "body-parser": "^1.18.3", - "byte-size": "^6.2.0", "clipboard-polyfill": "^2.4.6", "dotenv": "~8.2.0", "electron-updater": "^4.1.2", diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index d0fdd9d32..9f7ca6a57 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -20,6 +20,7 @@ import * as errors from '../infrastructure/errors'; import {sleep} from '../infrastructure/sleep'; import * as server from '../model/server'; +import {formatBytes} from './data_formatting'; import {TokenManager} from './digitalocean_oauth'; import * as digitalocean_server from './digitalocean_server'; import {parseManualServerConfig} from './management_urls'; @@ -666,7 +667,7 @@ export class App { view.serverHostname = server.getHostnameForAccessKeys(); view.serverManagementApiUrl = server.getManagementApiUrl(); view.serverPortForNewAccessKeys = server.getPortForNewAccessKeys(); - view.serverCreationDate = localizeDate(server.getCreatedDate(), this.appRoot.language); + view.serverCreationDate = server.getCreatedDate(); view.serverVersion = server.getVersion(); view.accessKeyDataLimit = dataLimitToDisplayDataAmount(server.getAccessKeyDataLimit()); view.isAccessKeyDataLimitEnabled = !!view.accessKeyDataLimit; @@ -686,7 +687,7 @@ export class App { view.monthlyCost = host.getMonthlyCost().usd; view.monthlyOutboundTransferBytes = host.getMonthlyOutboundTransferLimit().terabytes * (10 ** 12); - view.serverLocation = this.getLocalizedCityName(host.getRegionId()); + view.serverLocationId = digitalocean_server.GetCityId(host.getRegionId()); } else { view.isServerManaged = false; } @@ -772,7 +773,7 @@ export class App { for (const accessKeyId in stats.bytesTransferredByUserId) { totalBytes += stats.bytesTransferredByUserId[accessKeyId]; } - serverView.setServerTransferredData(totalBytes); + serverView.totalInboundBytes = totalBytes; const accessKeyDataLimit = selectedServer.getAccessKeyDataLimit(); if (accessKeyDataLimit) { @@ -1087,10 +1088,14 @@ export class App { }); } - private setAppLanguage(languageCode: string, languageDir: string) { - this.appRoot.setLanguage(languageCode, languageDir); - document.documentElement.setAttribute('dir', languageDir); - window.localStorage.setItem('overrideLanguage', languageCode); + private async setAppLanguage(languageCode: string, languageDir: string) { + try { + await this.appRoot.setLanguage(languageCode, languageDir); + document.documentElement.setAttribute('dir', languageDir); + window.localStorage.setItem('overrideLanguage', languageCode); + } catch (error) { + this.appRoot.showError(this.appRoot.localize('error-unexpected')); + } } private createLocationModel(cityId: string, regionIds: string[]): Location { diff --git a/src/server_manager/web_app/data_formatting.spec.ts b/src/server_manager/web_app/data_formatting.spec.ts new file mode 100644 index 000000000..eb20fd3bf --- /dev/null +++ b/src/server_manager/web_app/data_formatting.spec.ts @@ -0,0 +1,67 @@ +/* + Copyright 2020 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 * as i18n from './data_formatting'; + +describe('formatBytesParts', () => { + if (process?.versions?.node) { + it('doesn\'t run on Node', () => { + expect(() => i18n.formatBytesParts(0, 'en')).toThrow(); + }); + } else { + it('extracts the unit string and value separately', () => { + const english = i18n.formatBytesParts(0, 'en'); + expect(english.unit).toEqual('B'); + expect(english.value).toEqual('0'); + + const korean = i18n.formatBytesParts(2, 'kr'); + expect(korean.unit).toEqual('B'); + expect(korean.value).toEqual('2'); + + const russian = i18n.formatBytesParts(3000, 'ru'); + expect(russian.unit).toEqual('кБ'); + expect(russian.value).toEqual('3'); + + const simplifiedChinese = i18n.formatBytesParts(1.5 * 10 ** 9, 'zh-CN'); + expect(simplifiedChinese.unit).toEqual('吉字节'); + expect(simplifiedChinese.value).toEqual('1.5'); + + const farsi = i18n.formatBytesParts(133.5 * 10 ** 6, 'fa'); + expect(farsi.unit).toEqual('مگابایت'); + expect(farsi.value).toEqual('۱۳۳٫۵'); + }); + } +}); + +describe('formatBytes', () => { + if (process?.versions?.node) { + it('doesn\'t run on Node', () => { + expect(() => i18n.formatBytes(0, 'en')).toThrow(); + }); + } else { + it('Formats data amounts', () => { + expect(i18n.formatBytes(2.1, 'zh-TW')).toEqual('2 byte'); + expect(i18n.formatBytes(7.8 * 10 ** 3, 'ar')).toEqual('8 كيلوبايت'); + expect(i18n.formatBytes(1.5 * 10 ** 6, 'tr')).toEqual('1,5 MB'); + expect(i18n.formatBytes(10 * 10 ** 9, 'jp')).toEqual('10 GB'); + expect(i18n.formatBytes(2.35 * 10 ** 12, 'pr')).toEqual('2.35 TB'); + }); + + it('Omits trailing zero decimal digits', () => { + expect(i18n.formatBytes(10 ** 12, 'en')).toEqual('1 TB'); + }); + } +}); diff --git a/src/server_manager/web_app/data_formatting.ts b/src/server_manager/web_app/data_formatting.ts new file mode 100644 index 000000000..6d96aa1de --- /dev/null +++ b/src/server_manager/web_app/data_formatting.ts @@ -0,0 +1,107 @@ +/* + Copyright 2020 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 '@formatjs/intl-numberformat/polyfill' + +// Utility functions for internationalizing numbers and units + +// WARNING! This assumes an ES2020 target as this will always run on browser code in +// electron's built-in Chromium. This code shouldn't be used by anything running in Node. + +const TERABYTE = 10 ** 12; +const GIGABYTE = 10 ** 9; +const MEGABYTE = 10 ** 6; +const KILOBYTE = 10 ** 3; + +const inWebApp = typeof window !== 'undefined' && typeof window.document !== 'undefined'; +interface FormatParams { + value: number; + unit: 'terabyte'|'gigabyte'|'megabyte'|'kilobyte'|'byte'; + decimalPlaces: number; +} + +function getDataFormattingParams(numBytes: number): FormatParams { + if (numBytes >= TERABYTE) { + return {value: numBytes / TERABYTE, unit: 'terabyte', decimalPlaces: 2}; + } else if (numBytes >= GIGABYTE) { + return {value: numBytes / GIGABYTE, unit: 'gigabyte', decimalPlaces: 2}; + } else if (numBytes >= MEGABYTE) { + return {value: numBytes / MEGABYTE, unit: 'megabyte', decimalPlaces: 1}; + } else if (numBytes >= KILOBYTE) { + return {value: numBytes / KILOBYTE, unit: 'kilobyte', decimalPlaces: 0}; + } + return {value: numBytes, unit: 'byte', decimalPlaces: 0}; +} + +function makeDataAmountFormatter(language: string, params: FormatParams) { + // We need to cast through `unknown` since `tsc` mistakenly omits the 'unit' field in + // `NumberFormatOptions`. + const options = { + style: 'unit', + unit: params.unit, + unitDisplay: 'short', + maximumFractionDigits: params.decimalPlaces + } as unknown as Intl.NumberFormatOptions; + return new Intl.NumberFormat(language, options); +} + +interface DataAmountParts { + value: string; + unit: string; +} + +/** + * Returns a localized amount of bytes as a separate value and unit. This is useful for styling + * the unit and the value differently, or if you need them in separate nodes in the layout. + * + * @param {number} numBytes An amount of data to format. + * @param {string} language The ISO language code for the lanugage to translate to, eg 'en'. + */ +export function formatBytesParts(numBytes: number, language: string): DataAmountParts { + if (!inWebApp) { + throw new Error('formatBytesParts only works in web app code. Node usage isn\'t supported.'); + } + const params = getDataFormattingParams(numBytes); + const parts = makeDataAmountFormatter(language, params).formatToParts(params.value); + // Cast away the type since `tsc` mistakenly omits the possibility for a 'unit' part + const isUnit = (part: Intl.NumberFormatPart) => (part as {type: string}).type === 'unit'; + const unitText = parts.find(isUnit).value; + return { + value: parts.filter((part: Intl.NumberFormatPart) => !isUnit(part)) + .map((part: Intl.NumberFormatPart) => part.value) + .join('') + .trim(), + // Special case for "byte", since we'd rather be consistent with "KB", etc. "byte" is + // presumably used due to the example in the Unicode standard, + // http://unicode.org/reports/tr35/tr35-general.html#Example_Units + unit: unitText === 'byte' ? 'B' : unitText + }; +} + +/** + * Returns a string representation of a number of bytes, translated into the given language + * + * @param {Number} numBytes An amount of data to format. + * @param {string} language The ISO language code for the lanugage to translate to, eg 'en'. + * @returns {string} The formatted data amount. + */ +export function formatBytes(numBytes: number, language: string): string { + if (!inWebApp) { + throw new Error('formatBytes only works in web app code. Node usage isn\'t supported.'); + } + const params = getDataFormattingParams(numBytes); + return makeDataAmountFormatter(language, params).format(params.value); +} diff --git a/src/server_manager/web_app/karma.conf.js b/src/server_manager/web_app/karma.conf.js index bcdf34e3d..37ef9ab6b 100644 --- a/src/server_manager/web_app/karma.conf.js +++ b/src/server_manager/web_app/karma.conf.js @@ -19,15 +19,22 @@ const baseConfig = makeConfig({ defaultMode: 'development' }); +const test_patterns = [ + '**/*.spec.ts', + // We need to test data_formatting in a browser context + './data_formatting.spec.ts' +]; + +let preprocessors = {}; +for (const pattern of test_patterns) { + preprocessors[pattern] = ['webpack']; +} + module.exports = function(config) { config.set({ frameworks: ['jasmine'], - files: [ - '**/*.spec.ts', - ], - preprocessors: { - '**/*.spec.ts': ['webpack'], - }, + files: test_patterns, + preprocessors, reporters: ['progress'], colors: true, logLevel: config.LOG_INFO, diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts index 0cb2bb536..df46bf09a 100644 --- a/src/server_manager/web_app/main.ts +++ b/src/server_manager/web_app/main.ts @@ -115,6 +115,7 @@ document.addEventListener('WebComponentsReady', () => { // 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 AppRoot; + appRoot.language = language.string(); const filteredLanguageDefs = Object.values(SUPPORTED_LANGUAGES); appRoot.supportedLanguages = sortLanguageDefsByName(filteredLanguageDefs); 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 56d4d0c93..0f65f1e2e 100644 --- a/src/server_manager/web_app/ui_components/app-root.js +++ b/src/server_manager/web_app/ui_components/app-root.js @@ -418,7 +418,7 @@ export class AppRoot extends mixinBehaviors
@@ -555,18 +555,47 @@ export class AppRoot extends mixinBehaviors } /** - * Sets the language and direction for the application - * @param {string} language - * @param {string} direction + * Loads a new translation file and returns a Promise which resolves when the file is loaded or + * rejects when there was an error loading translations. + * + * @param {string} language The language code to load translations for, eg 'en' */ - setLanguage(language, direction) { + async loadLanguageResources(language) { + const localizeResourcesResponder = new Promise((resolve, reject) => { + // loadResources uses events and continuation instead of Promises. In order to make this + // function easier to use, we wrap the language-changing logic in event handlers which + // resolve or reject the Promise. Note that they need to clean up whichever event handler + // didn't fire so we don't leak it, which could cause future language changes to not work + // properly by triggering old event listeners. + let successHandler, failureHandler; + successHandler = () => { + this.removeEventListener('app-localize-resources-error', failureHandler); + resolve(); + }; + failureHandler = (event) => { + this.removeEventListener('app-localize-resources-loaded', successHandler); + reject(new Error(`Failed to load resources for language ${language}`)); + }; + this.addEventListener('app-localize-resources-loaded', successHandler, {once: true}); + this.addEventListener('app-localize-resources-error', failureHandler, {once: true}); + }); + const messagesUrl = `./messages/${language}.json`; this.loadResources(messagesUrl, language); + return localizeResourcesResponder; + } + + /** + * Sets the language and direction for the application + * @param {string} language The ISO language code for the new language, eg 'en' + * @param {string} direction The direction of the language, either 'rtl' or 'ltr' + */ + async setLanguage(language, direction) { + await this.loadLanguageResources(language); const alignDir = direction === 'ltr' ? 'left' : 'right'; this.$.appDrawer.align = alignDir; this.$.sideBar.align = alignDir; - this.language = language; } diff --git a/src/server_manager/web_app/ui_components/outline-server-settings.js b/src/server_manager/web_app/ui_components/outline-server-settings.js index a46c87ab7..3dd5ab972 100644 --- a/src/server_manager/web_app/ui_components/outline-server-settings.js +++ b/src/server_manager/web_app/ui_components/outline-server-settings.js @@ -26,6 +26,8 @@ import './outline-validated-input.js'; import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js'; import {html} from '@polymer/polymer/lib/utils/html-tag.js'; +import {formatBytesParts} from '../data_formatting'; + Polymer({ _template: html` @@ -138,7 +140,7 @@ Polymer({ .data-limits-input paper-dropdown-menu { border: none; --paper-input-container: { - width: 64px; + width: 72px; } } paper-listbox paper-item { @@ -175,7 +177,7 @@ Polymer({

DigitalOcean

- +
@@ -189,7 +191,7 @@ Polymer({ - + @@ -220,8 +222,8 @@ Polymer({ - MB - GB + [[_getInternationalizedUnit(1000000, language)]] + [[_getInternationalizedUnit(1000000000, language)]] @@ -274,10 +276,11 @@ Polymer({ {type: Boolean, value: false}, // Whether the server supports data limits. showFeatureMetricsDisclaimer: {type: Boolean, value: false}, isHostnameEditable: {type: Boolean, value: true}, - serverCreationDate: {type: String, value: null}, + serverCreationDate: {type: Date, value: '1970-01-01T00:00:00.000Z'}, serverLocation: {type: String, value: null}, serverMonthlyCost: {type: String, value: null}, serverMonthlyTransferLimit: {type: String, value: null}, + language: {type: String, value: 'en'}, localize: {type: Function, readonly: true}, shouldShowExperiments: {type: Boolean, value: false}, }, @@ -356,5 +359,13 @@ Polymer({ const port = Number(value); const valid = !Number.isNaN(port) && port >= 1 && port <= 65535 && Number.isInteger(port); return valid ? '' : this.localize('error-keys-port-bad-input'); + }, + + _getInternationalizedUnit(bytesAmount, language) { + return formatBytesParts(bytesAmount, language).unit; + }, + + _formatDate(language, date) { + return date.toLocaleString(language, {year: 'numeric', month: 'long', day: 'numeric'}); } }); 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 c23975e8a..03fc003f0 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 @@ -36,15 +36,7 @@ import './outline-sort-span.js'; import {html, PolymerElement} from '@polymer/polymer'; import {DirMixin} from '@polymer/polymer/lib/mixins/dir-mixin.js'; - -import * as byte_size from 'byte-size'; - -byte_size.defaultOptions({ - units: 'metric', - toStringFn: function() { - return `${this.value} ${this.unit}`; - }, -}); +import * as i18n from '../data_formatting'; const MY_CONNECTION_USER_ID = '0'; @@ -265,7 +257,7 @@ export class ServerView extends DirMixin(PolymerElement) { } .measurement { /* Space the usage bars evenly */ - min-width: 9ch; + min-width: 11ch; /* We don't want numbers separated from their units */ white-space: nowrap; font-size: 14px; @@ -429,10 +421,10 @@ export class ServerView extends DirMixin(PolymerElement) { [[localize('ok')]] `; - } + } - static get unreachableViewTemplate() { - return html` + static get unreachableViewTemplate() { + return html`

[[serverName]]

@@ -451,10 +443,10 @@ export class ServerView extends DirMixin(PolymerElement) { [[localize('retry')]]
`; - } + } - static get managementViewTemplate() { - return html` + static get managementViewTemplate() { + return html`

[[serverName]]

@@ -470,7 +462,7 @@ export class ServerView extends DirMixin(PolymerElement) {
-
[[serverLocation]]
+
[[_formatLocation(serverLocationId, language)]]
@@ -485,8 +477,8 @@ export class ServerView extends DirMixin(PolymerElement) {
-

[[_getFormattedTransferredValue(totalInboundBytes, '0')]]

-

[[_getFormattedTransferredUnit(totalInboundBytes, 'B')]]

+

[[_formatInboundBytesValue(totalInboundBytes, language)]]

+

[[_formatInboundBytesUnit(totalInboundBytes, language)]]

[[localize('server-data-transfer')]]

@@ -496,7 +488,7 @@ export class ServerView extends DirMixin(PolymerElement) {

[[managedServerUtilzationPercentage]]

-

/[[_formatBytesTransferred(monthlyOutboundTransferBytes)]]

+

/[[_formatBytesTransferred(monthlyOutboundTransferBytes, language)]]

[[localize('server-data-used')]]

@@ -535,7 +527,7 @@ export class ServerView extends DirMixin(PolymerElement) { - [[_formatBytesTransferred(myConnection.transferredBytes, "...")]] + [[_formatBytesTransferred(myConnection.transferredBytes, language, "...")]] [[_getDataLimitsUsageString(myConnection)]] @@ -558,7 +550,7 @@ export class ServerView extends DirMixin(PolymerElement) { - [[_formatBytesTransferred(item.transferredBytes, "...")]] + [[_formatBytesTransferred(item.transferredBytes, language, "...")]] [[_getDataLimitsUsageString(item)]] @@ -595,11 +587,11 @@ export class ServerView extends DirMixin(PolymerElement) {
- +
`; - } + } static get is() { return 'outline-server-view'; @@ -615,8 +607,8 @@ export class ServerView extends DirMixin(PolymerElement) { serverManagementApiUrl: String, serverPortForNewAccessKeys: Number, isAccessKeyPortEditable: {type: Boolean}, - serverCreationDate: String, - serverLocation: String, + serverCreationDate: {type: Date}, + serverLocationId: String, accessKeyDataLimit: {type: Object}, isAccessKeyDataLimitEnabled: {type: Boolean}, supportsAccessKeyDataLimit: {type: Boolean}, @@ -638,6 +630,7 @@ export class ServerView extends DirMixin(PolymerElement) { }, accessKeySortBy: {type: String}, accessKeySortDirection: {type: Number}, + language: {type: String}, localize: {type: Function, readonly: true}, selectedPage: {type: String}, selectedTab: {type: String}, @@ -662,8 +655,8 @@ export class ServerView extends DirMixin(PolymerElement) { /** @type {number} */ this.serverPortForNewAccessKeys = null; this.isAccessKeyPortEditable = false; - this.serverCreationDate = ''; - this.serverLocation = ''; + this.serverCreationDate = new Date(0); + this.serverLocationId = ''; /** @type {DisplayDataAmount} */ this.accessKeyDataLimit = null; this.isAccessKeyDataLimitEnabled = false; @@ -702,6 +695,7 @@ export class ServerView extends DirMixin(PolymerElement) { * @type {-1|1} */ this.accessKeySortDirection = 1; + this.language = 'en'; /** @type {(msgId: string, ...params: string[]) => string} */ this.localize = null; /** @type {'progressView'|'unreachableView'|'managementView'} */ @@ -850,43 +844,53 @@ export class ServerView extends DirMixin(PolymerElement) { this.dispatchEvent(makePublicEvent('RemoveAccessKeyRequested', {accessKeyId: accessKey.id})); } - _formatBytesTransferred(numBytes, emptyValue = '') { - if (!numBytes) { - // numBytes may not be set for manual servers, or may be 0 for - // unused access keys. - return emptyValue; + _formatInboundBytesUnit(totalBytes, language) { + // This happens during app startup before we set the language + if (!language) { + return ''; } + return i18n.formatBytesParts(totalBytes, language).unit; + } - // Show 0 decimals for < 1MB, 1 decimal for >= 1MB, 2 decimals for >= 1GB. - let numDecimals = 0; - if (numBytes >= 10 ** 9) { - numDecimals = 2; - } else if (numBytes >= 10 ** 6) { - numDecimals = 1; + _formatInboundBytesValue(totalBytes, language) { + // This happens during app startup before we set the language + if (!language) { + return ''; } - - return byte_size(numBytes, {precision: numDecimals}).toString(); + return i18n.formatBytesParts(totalBytes, language).value; } - _getFormattedTransferredUnit(numBytes, emptyValue = '') { - const formattedTransfer = this._formatBytesTransferred(numBytes); - if (!formattedTransfer) { - return emptyValue; + updateAccessKeyRow(accessKeyId, fields) { + let newAccessKeyRow; + if (accessKeyId === MY_CONNECTION_USER_ID) { + newAccessKeyRow = Object.assign({}, this.get('myConnection'), fields); + this.set('myConnection', newAccessKeyRow); } - const units = formattedTransfer.match(/[a-zA-Z%]+/g); - if (!units || units.length < 0) { - return emptyValue; + for (let ui in this.accessKeyRows) { + if (this.accessKeyRows[ui].id === accessKeyId) { + newAccessKeyRow = Object.assign({}, this.get(['accessKeyRows', ui]), fields); + this.set(['accessKeyRows', ui], newAccessKeyRow); + return; + } } - return units.pop(); } - _getFormattedTransferredValue(numBytes, emptyValue = '') { - const formattedTransfer = this._formatBytesTransferred(numBytes); - if (!formattedTransfer) { + _formatBytesTransferred(numBytes, language, emptyValue = '') { + if (!numBytes) { + // numBytes may not be set for manual servers, or may be 0 for + // unused access keys. return emptyValue; } - const value = parseFloat(formattedTransfer); - return !!value ? value : emptyValue; + return i18n.formatBytes(numBytes, language); + } + + _formatMonthlyCost(monthlyCost, language) { + if (!monthlyCost) { + return ''; + } + return new Intl + .NumberFormat(language, {style: 'currency', currency: 'USD', currencyDisplay: 'code'}) + .format(monthlyCost); } _computeManagedServerUtilzationPercentage(numBytes, monthlyLimitBytes) { @@ -1002,10 +1006,18 @@ export class ServerView extends DirMixin(PolymerElement) { if (!this.accessKeyDataLimit) { return ''; } - const used = this._formatBytesTransferred(accessKey.transferredBytes, '0'); + const used = this._formatBytesTransferred(accessKey.transferredBytes, this.language, '0'); const total = this.accessKeyDataLimit.value + ' ' + this.accessKeyDataLimit.unit; return this.localize('data-limits-usage', 'used', used, 'total', total); } + + // Takes language so that the server location is recalculated on app language change. + _formatLocation(serverLocationId, UNUSED_language) { + if (!serverLocationId) { + return ''; + } + return this.localize(`city-${serverLocationId}`); + } } customElements.define(ServerView.is, ServerView); diff --git a/src/shadowbox/infrastructure/prometheus_scraper.ts b/src/shadowbox/infrastructure/prometheus_scraper.ts index 67557e7f6..ff7ed7167 100644 --- a/src/shadowbox/infrastructure/prometheus_scraper.ts +++ b/src/shadowbox/infrastructure/prometheus_scraper.ts @@ -80,7 +80,7 @@ async function writePrometheusConfigToDisk(configFilename: string, configJson: { await mkdirp.sync(path.dirname(configFilename)); const ymlTxt = jsyaml.safeDump(configJson, {'sortKeys': true}); // Write the file asynchronously to prevent blocking the node thread. - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { fs.writeFile(configFilename, ymlTxt, 'utf-8', (err) => { if (err) { reject(err); diff --git a/yarn.lock b/yarn.lock index b76f9c9e6..ceee626df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2165,11 +2165,6 @@ bunyan@^1.8.12: mv "~2" safe-json-stringify "~1" -byte-size@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-6.2.0.tgz#39fd52adedbbf7e8c3b3f7dea05e441549375c28" - integrity sha512-6EspYUCAPMc7E2rltBgKwhG+Cmk0pDm9zDtF1Awe2dczNUL3YpZ8mTs/dueOTS1hqGWBOatqef4jYMGjln7WmA== - bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -6987,20 +6982,15 @@ node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" - integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== - node-forge@0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== -node-forge@^0.7.1: - version "0.7.6" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" - integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== +node-forge@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== node-forge@^0.8.0: version "0.8.5" @@ -9884,10 +9874,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^3.8.3: - version "3.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.3.tgz#d3ac8883a97c26139e42df5e93eeece33d610b8a" - integrity sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ== +typescript@^4: + version "4.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" + integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== ua-parser-js@0.7.22: version "0.7.22"