diff --git a/src/server_manager/messages/en.json b/src/server_manager/messages/en.json index 9c071c3aa..3a26ea7d7 100644 --- a/src/server_manager/messages/en.json +++ b/src/server_manager/messages/en.json @@ -21,6 +21,7 @@ "confirmation-server-destroy-title": "Destroy Server?", "confirmation-server-remove": "This action removes your server from the Outline Manager, but does not block proxy access to users. You will still need to manually delete the Outline server from your host machine.", "confirmation-server-remove-title": "Remove Server?", + "data-limit": "Data Limit", "data-limit-per-key": "Data limit per key", "data-limits": "Data limits", "data-limits-description": "Set a 30 day trailing data transfer limit for access keys on this server.", @@ -52,7 +53,8 @@ "error-metrics": "Error setting metrics enabled", "error-network": "A network error occurred.", "error-not-saved": "Not Saved", - "error-remove-data-limit": "Could not disable access key data limit", + "error-remove-data-limit": "Could not disable default data limit", + "error-remove-per-key-limit": "Could not remove data limit from this access key", "error-server-creation": "There was an error creating your Outline server.", "error-server-destroy": "Failed to destroy server", "error-server-removed": "{serverName} no longer present in your DigitalOcean account.", @@ -60,7 +62,8 @@ "error-server-unreachable": "Your Outline Server was installed correctly, but we are not able to connect to it. Most likely this is because your server's firewall rules are blocking incoming connections. Please review them and make sure to allow incoming TCP connections on ports ranging from 1024 to 65535.", "error-server-unreachable-title": "Unable to connect to your Outline Server", "error-servers-removed": "{serverNames} no longer present in your DigitalOcean account.", - "error-set-data-limit": "Could not set access key data limit", + "error-set-data-limit": "Could not set default data limit", + "error-set-per-key-limit": "Could not set data limit for this access key", "error-unexpected": "An unexpected error occurred.", "experiments": "Experiments", "experiments-description": "Test new features and provide us with feedback before they are released.", @@ -125,6 +128,7 @@ "nav-licenses": "Licenses", "nav-privacy": "Privacy", "nav-terms": "Terms", + "no-data-limit": "None", "notification-app-update": "An updated version of the Outline Manager has been downloaded. It will be installed when you restart the application.", "notification-feedback-thanks": "Thanks for helping us improve! We love hearing from you.", "notification-key-added": "Key added", @@ -144,6 +148,8 @@ "oauth-verify": "Check your inbox for an email from DigitalOcean, and click the link in it to confirm your account.", "oauth-verify-tag": "Confirm your email...", "okay": "OK", + "per-key-data-limit-dialog-set-custom": "Set a custom data limit", + "per-key-data-limit-dialog-title": "Data Limit - {keyName}", "region-description": "This is where your internet experience will come from.", "region-setup": "Set up Outline", "region-title": "Select the location of your server.", diff --git a/src/server_manager/messages/master_messages.json b/src/server_manager/messages/master_messages.json index 1e53f1caa..0b8d126de 100644 --- a/src/server_manager/messages/master_messages.json +++ b/src/server_manager/messages/master_messages.json @@ -121,6 +121,10 @@ "message": "Remove Server?", "description": "This string appears in a dialog that requests user confirmation for removing a server from the application. 'Remove' in this context does not imply server deletion." }, + "data_limit": { + "message": "Data Limit", + "Description": "This string appears in various places related to the data transfer limit for a single access key." + }, "data_limit_per_key": { "message": "Data limit per key", "description": "This string appears as a label to an input to set the default access key data limit for a server." @@ -308,12 +312,20 @@ "description": "This string signifies that an error we didn't expect was encountered." }, "error_set_data_limit": { - "message": "Could not set access key data limit", - "description": "This string appears in an error notification toast. It is shown on failure to set a data transfer limit on access keys." + "message": "Could not set default data limit", + "description": "This string appears in an error notification toast. It is shown on failure to set the default data transfer limit on access keys." }, "error_remove_data_limit": { - "message": "Could not disable access key data limit", - "description": "This string appears in an error notification toast. It is shown on failure to remove a data transfer limit on access keys." + "message": "Could not disable default data limit", + "description": "This string appears in an error notification toast. It is shown on failure to remove the default data transfer limit on access keys." + }, + "error_set_per_key_limit": { + "message": "Could not set data limit for this access key", + "description": "This string appears in an error notification toast. It is shown on failure to set the data transfer limit on a particular access key." + }, + "error_remove_per_key_limit": { + "message": "Could not remove data limit from this access key", + "description": "This string appears in an error notification toast. It is shown on failure to remove the data transfer limit on a particular access key." }, "experiments": { "message": "Experiments", @@ -607,6 +619,10 @@ "message": "About", "description": "This string appears in an application drawer as a navigation link. Clicking it opens a dialog with information about Outline." }, + "no_data_limit": { + "message": "None", + "description": "This string appears alongside each access key in the data transfer stats section if it is under no data limit. Example: 450 MB / None" + }, "nav_data_collection": { "message": "Data collection", "description": "This string appears in an application drawer as a navigation link. Clicking it opens Outline's data collection policy in the browser." @@ -707,6 +723,20 @@ "message": "OK", "description": "This string appears across the application as a button. It lets the user acknowledge displayed information. Clicking dismisses the enclosing UI element." }, + "per_key_data_limit_dialog_set_custom": { + "message": "Set a custom data limit", + "description": "This string appears next to a checkbox in the per-key data limit dialog which, when selected, shows the input to add a data limit to an access key" + }, + "per_key_data_limit_dialog_title": { + "message": "Data Limit - $KEY_NAME$", + "description": "This string appears as the title of the per-key data limit dialog.", + "placeholders": { + "KEY_NAME": { + "content": "{keyName}", + "Example": "Key 1" + } + } + }, "region_description": { "message": "This is where your internet experience will come from.", "description": "This string appears within the server creation flow, as a sub-header of the server selection view. Lets the user know about the implications of selecting a server location." diff --git a/src/server_manager/model/server.ts b/src/server_manager/model/server.ts index 07d14e929..7f3c81841 100644 --- a/src/server_manager/model/server.ts +++ b/src/server_manager/model/server.ts @@ -40,14 +40,26 @@ export interface Server { // Removes the access key given by id. removeAccessKey(accessKeyId: AccessKeyId): Promise; - // Sets a data transfer limit over a 30 day rolling window for all access keys. - setAccessKeyDataLimit(limit: DataLimit): Promise; + // Sets a default access key data transfer limit over a 30 day rolling window for all access keys. + // This limit is overridden by per-key data limits. Forces enforcement of all data limits, + // including per-key data limits. + setDefaultDataLimit(limit: DataLimit): Promise; - // Returns the access key data transfer limit, or undefined if it has not been set. - getAccessKeyDataLimit(): DataLimit|undefined; + // Returns the server default access key data transfer limit, or undefined if it has not been set. + getDefaultDataLimit(): DataLimit|undefined; - // Removes the access key data transfer limit. - removeAccessKeyDataLimit(): Promise; + // Removes the server default data limit. Per-key data limits are still enforced. Traffic is + // tracked for if the limit is re-enabled. Forces enforcement of all data limits, including + // per-key limits. + removeDefaultDataLimit(): Promise; + + // Sets the custom data limit for a specific key. This limit overrides the server default limit + // if it exists. Forces enforcement of the chosen key's data limit. + setAccessKeyDataLimit(accessKeyId: AccessKeyId, limit: DataLimit): Promise; + + // Removes the custom data limit for a specific key. The key is still bound by the server default + // limit if it exists. Forces enforcement of the chosen key's data limit. + removeAccessKeyDataLimit(accessKeyId: AccessKeyId): Promise; // Returns whether metrics are enabled. getMetricsEnabled(): boolean; @@ -114,7 +126,9 @@ export interface ManagedServerHost { getHostId(): string; } -export class DataAmount { terabytes: number; } +export class DataAmount { + terabytes: number; +} export class MonetaryCost { // Value in US dollars. @@ -148,6 +162,7 @@ export interface AccessKey { id: AccessKeyId; name: string; accessUrl: string; + dataLimit?: DataLimit; } export type BytesByAccessKey = Map; diff --git a/src/server_manager/model/survey.ts b/src/server_manager/model/survey.ts index 0173c5a99..e412a9752 100644 --- a/src/server_manager/model/survey.ts +++ b/src/server_manager/model/survey.ts @@ -13,8 +13,8 @@ // limitations under the License. export interface Surveys { - // Displays a survey when the data limits feature is enabled. + // Displays a survey when a server default data limit is set. presentDataLimitsEnabledSurvey(): Promise; - // Displays a survey when the data limits feature is disabled. + // Displays a survey when a server default data limit is removed. presentDataLimitsDisabledSurvey(): Promise; } diff --git a/src/server_manager/web_app/app.spec.ts b/src/server_manager/web_app/app.spec.ts index 74b99c1f6..e40c86746 100644 --- a/src/server_manager/web_app/app.spec.ts +++ b/src/server_manager/web_app/app.spec.ts @@ -145,7 +145,8 @@ function createTestApp( manualServerRepo?: server.ManualServerRepository) { const VERSION = '0.0.1'; if (!cloudAccounts) { - cloudAccounts = new CloudAccounts((token: string) => new FakeDigitalOceanAccount(), new InMemoryStorage()); + cloudAccounts = + new CloudAccounts((token: string) => new FakeDigitalOceanAccount(), new InMemoryStorage()); } if (!manualServerRepo) { manualServerRepo = new FakeManualServerRepository(); @@ -220,13 +221,19 @@ class FakeServer implements server.Server { setPortForNewAccessKeys(): Promise { return Promise.reject(new Error('FakeServer.setPortForNewAccessKeys not implemented')); } - setAccessKeyDataLimit(limit: server.DataLimit): Promise { + setAccessKeyDataLimit(accessKeyId: string, limit: server.DataLimit): Promise { return Promise.reject(new Error('FakeServer.setAccessKeyDataLimit not implemented')); } - removeAccessKeyDataLimit(): Promise { + removeAccessKeyDataLimit(accessKeyId: string): Promise { + return Promise.reject(new Error('FakeServer.removeAccessKeyDataLimit not implemented')); + } + setDefaultDataLimit(limit: server.DataLimit): Promise { + return Promise.reject(new Error('FakeServer.setDefaultDataLimit not implemented')); + } + removeDefaultDataLimit(): Promise { return Promise.resolve(); } - getAccessKeyDataLimit(): server.DataLimit|undefined { + getDefaultDataLimit(): server.DataLimit|undefined { return undefined; } } diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index 89ab21f63..7b068a104 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -18,15 +18,17 @@ import * as semver from 'semver'; import * as digitalocean_api from '../cloud/digitalocean_api'; import * as errors from '../infrastructure/errors'; import {sleep} from '../infrastructure/sleep'; -import * as server from '../model/server'; import * as digitalocean from '../model/digitalocean'; +import * as server from '../model/server'; import {CloudAccounts} from './cloud_accounts'; +import {bytesToDisplayDataAmount, DisplayDataAmount, displayDataAmountToBytes,} from './data_formatting'; import * as digitalocean_server from './digitalocean_server'; import {parseManualServerConfig} from './management_urls'; import {AppRoot, ServerListEntry} from './ui_components/app-root'; +import {OutlinePerKeyDataLimitDialog} from './ui_components/outline-per-key-data-limit-dialog.js'; import {Location} from './ui_components/outline-region-picker-step'; -import {DisplayAccessKey, DisplayDataAmount, ServerView} from './ui_components/outline-server-view'; +import {DisplayAccessKey, ServerView} from './ui_components/outline-server-view'; // The Outline DigitalOcean team's referral code: // https://www.digitalocean.com/help/referral-program/ @@ -35,6 +37,7 @@ const UNUSED_DIGITALOCEAN_REFERRAL_CODE = '5ddb4219b716'; const CHANGE_KEYS_PORT_VERSION = '1.0.0'; const DATA_LIMITS_VERSION = '1.1.0'; const CHANGE_HOSTNAME_VERSION = '1.2.0'; +const KEY_SETTINGS_VERSION = '1.6.0'; const MAX_ACCESS_KEY_DATA_LIMIT_BYTES = 50 * (10 ** 9); // 50GB const CANCELLED_ERROR = new Error('Cancelled'); export const LAST_DISPLAYED_SERVER_STORAGE_KEY = 'lastDisplayedServer'; @@ -53,31 +56,19 @@ const DIGITALOCEAN_FLAG_MAPPING: {[cityId: string]: string} = { }; function dataLimitToDisplayDataAmount(limit: server.DataLimit): DisplayDataAmount|null { - if (!limit) { - return null; - } - const bytes = limit.bytes; - if (bytes >= 10 ** 9) { - return {value: Math.floor(bytes / (10 ** 9)), unit: 'GB'}; - } - return {value: Math.floor(bytes / (10 ** 6)), unit: 'MB'}; + return bytesToDisplayDataAmount(limit?.bytes); } - function displayDataAmountToDataLimit(dataAmount: DisplayDataAmount): server.DataLimit|null { if (!dataAmount) { return null; } - if (dataAmount.unit === 'GB') { - return {bytes: dataAmount.value * (10 ** 9)}; - } else if (dataAmount.unit === 'MB') { - return {bytes: dataAmount.value * (10 ** 6)}; - } - return {bytes: dataAmount.value}; + + return {bytes: displayDataAmountToBytes(dataAmount)}; } // Compute the suggested data limit based on the server's transfer capacity and number of access // keys. -async function computeDefaultAccessKeyDataLimit( +async function computeDefaultDataLimit( server: server.Server, accessKeys?: server.AccessKey[]): Promise { try { // Assume non-managed servers have a data transfer capacity of 1TB. @@ -116,7 +107,7 @@ async function showHelpBubblesOnce(serverView: ServerView) { window.localStorage.setItem('getConnectedHelpBubble-dismissed', 'true'); } if (!window.localStorage.getItem('dataLimitsHelpBubble-dismissed') && - serverView.supportsAccessKeyDataLimit) { + serverView.supportsDefaultDataLimit) { await serverView.showDataLimitsHelpBubble(); window.localStorage.setItem('dataLimitsHelpBubble-dismissed', 'true'); } @@ -178,16 +169,19 @@ export class App { this.removeAccessKey(event.detail.accessKeyId); }); + appRoot.addEventListener( + 'OpenPerKeyDataLimitDialogRequested', this.openPerKeyDataLimitDialog.bind(this)); + appRoot.addEventListener('RenameAccessKeyRequested', (event: CustomEvent) => { this.renameAccessKey(event.detail.accessKeyId, event.detail.newName, event.detail.entry); }); - appRoot.addEventListener('SetAccessKeyDataLimitRequested', (event: CustomEvent) => { - this.setAccessKeyDataLimit(displayDataAmountToDataLimit(event.detail.limit)); + appRoot.addEventListener('SetDefaultDataLimitRequested', (event: CustomEvent) => { + this.setDefaultDataLimit(displayDataAmountToDataLimit(event.detail.limit)); }); - appRoot.addEventListener('RemoveAccessKeyDataLimitRequested', (event: CustomEvent) => { - this.removeAccessKeyDataLimit(); + appRoot.addEventListener('RemoveDefaultDataLimitRequested', (event: CustomEvent) => { + this.removeDefaultDataLimit(); }); appRoot.addEventListener('ChangePortForNewAccessKeysRequested', (event: CustomEvent) => { @@ -652,24 +646,26 @@ export class App { private setServerManagementView(server: server.Server): void { // Show view and initialize fields from selectedServer. const view = this.appRoot.getServerView(server.getId()); + const version = server.getVersion(); view.selectedPage = 'managementView'; - view.serverId = server.getMetricsId(); + view.serverId = server.getId(); + view.metricsId = server.getMetricsId(); view.serverName = server.getName(); view.serverHostname = server.getHostnameForAccessKeys(); view.serverManagementApiUrl = server.getManagementApiUrl(); view.serverPortForNewAccessKeys = server.getPortForNewAccessKeys(); view.serverCreationDate = server.getCreatedDate(); - view.serverVersion = server.getVersion(); - view.accessKeyDataLimit = dataLimitToDisplayDataAmount(server.getAccessKeyDataLimit()); - view.isAccessKeyDataLimitEnabled = !!view.accessKeyDataLimit; + view.serverVersion = version; + view.defaultDataLimitBytes = server.getDefaultDataLimit()?.bytes; + view.isDefaultDataLimitEnabled = view.defaultDataLimitBytes !== undefined; view.showFeatureMetricsDisclaimer = server.getMetricsEnabled() && - !server.getAccessKeyDataLimit() && !hasSeenFeatureMetricsNotification(); + !server.getDefaultDataLimit() && !hasSeenFeatureMetricsNotification(); - const version = server.getVersion(); if (version) { view.isAccessKeyPortEditable = semver.gte(version, CHANGE_KEYS_PORT_VERSION); - view.supportsAccessKeyDataLimit = semver.gte(version, DATA_LIMITS_VERSION); + view.supportsDefaultDataLimit = semver.gte(version, DATA_LIMITS_VERSION); view.isHostnameEditable = semver.gte(version, CHANGE_HOSTNAME_VERSION); + view.hasPerKeyDataLimitDialog = semver.gte(version, KEY_SETTINGS_VERSION); } if (isManagedServer(server)) { @@ -692,9 +688,9 @@ export class App { try { const serverAccessKeys = await server.listAccessKeys(); view.accessKeyRows = serverAccessKeys.map(this.convertToUiAccessKey.bind(this)); - if (!view.accessKeyDataLimit) { - view.accessKeyDataLimit = dataLimitToDisplayDataAmount( - await computeDefaultAccessKeyDataLimit(server, serverAccessKeys)); + if (view.defaultDataLimitBytes === undefined) { + view.defaultDataLimitBytes = + (await computeDefaultDataLimit(server, serverAccessKeys))?.bytes; } // Show help bubbles once the page has rendered. setTimeout(() => { @@ -759,31 +755,24 @@ export class App { private async refreshTransferStats(selectedServer: server.Server, serverView: ServerView) { try { const usageMap = await selectedServer.getDataUsage(); - let totalBytes = 0; - for (const accessKeyBytes of usageMap.values()) { - totalBytes += accessKeyBytes; - } - serverView.totalInboundBytes = totalBytes; - - const accessKeyDataLimit = selectedServer.getAccessKeyDataLimit(); - if (accessKeyDataLimit) { - // Make access key data usage relative to the data limit. - totalBytes = accessKeyDataLimit.bytes; + const keyTransfers = [...usageMap.values()]; + let totalInboundBytes = 0; + for (const accessKeyBytes of keyTransfers) { + totalInboundBytes += accessKeyBytes; } - - // Update all the displayed access keys, even if usage didn't change, in case the data limit - // did. - for (const accessKey of serverView.accessKeyRows) { - const accessKeyId = accessKey.id; - const transferredBytes = usageMap.get(accessKeyId) ?? 0; - let relativeTraffic = - totalBytes ? 100 * transferredBytes / totalBytes : (accessKeyDataLimit ? 100 : 0); - if (relativeTraffic > 100) { - // Can happen when a data limit is set on an access key that already exceeds it. - relativeTraffic = 100; - } - serverView.updateAccessKeyRow(accessKeyId, {transferredBytes, relativeTraffic}); + serverView.totalInboundBytes = totalInboundBytes; + + // Update all the displayed access keys, even if usage didn't change, in case data limits did. + let keyTransferMax = 0; + let dataLimitMax = selectedServer.getDefaultDataLimit()?.bytes ?? 0; + for (const key of await selectedServer.listAccessKeys()) { + serverView.updateAccessKeyRow( + key.id, + {transferredBytes: usageMap.get(key.id) ?? 0, dataLimitBytes: key.dataLimit?.bytes}); + keyTransferMax = Math.max(keyTransferMax, usageMap.get(key.id) ?? 0); + dataLimitMax = Math.max(dataLimitMax, key.dataLimit?.bytes ?? 0); } + serverView.baselineDataTransfer = Math.max(keyTransferMax, dataLimitMax); } catch (e) { // Since failures are invisible to users we generally want exceptions here to bubble // up and trigger a Sentry report. The exception is network errors, about which we can't @@ -817,16 +806,15 @@ export class App { encodeURIComponent(accessUrl)}`; } - // Converts the access key from the remote service format to the - // format used by outline-server-view. + // Converts the access key model to the format used by outline-server-view. private convertToUiAccessKey(remoteAccessKey: server.AccessKey): DisplayAccessKey { return { id: remoteAccessKey.id, - placeholderName: `${this.appRoot.localize('key', 'keyId', remoteAccessKey.id)}`, + placeholderName: this.appRoot.localize('key', 'keyId', remoteAccessKey.id), name: remoteAccessKey.name, accessUrl: remoteAccessKey.accessUrl, transferredBytes: 0, - relativeTraffic: 0 + dataLimitBytes: remoteAccessKey.dataLimit?.bytes, }; } @@ -855,42 +843,93 @@ export class App { }); } - private async setAccessKeyDataLimit(limit: server.DataLimit) { + private async setDefaultDataLimit(limit: server.DataLimit) { if (!limit) { return; } - const previousLimit = this.selectedServer.getAccessKeyDataLimit(); + const previousLimit = this.selectedServer.getDefaultDataLimit(); if (previousLimit && limit.bytes === previousLimit.bytes) { return; } const serverView = this.appRoot.getServerView(this.appRoot.selectedServerId); try { - await this.selectedServer.setAccessKeyDataLimit(limit); + await this.selectedServer.setDefaultDataLimit(limit); this.appRoot.showNotification(this.appRoot.localize('saved')); - serverView.accessKeyDataLimit = dataLimitToDisplayDataAmount(limit); + serverView.defaultDataLimitBytes = limit?.bytes; + serverView.isDefaultDataLimitEnabled = true; this.refreshTransferStats(this.selectedServer, serverView); // Don't display the feature collection disclaimer anymore. serverView.showFeatureMetricsDisclaimer = false; window.localStorage.setItem('dataLimits-feature-collection-notification', 'true'); } catch (error) { - console.error(`Failed to set access key data limit: ${error}`); + console.error(`Failed to set server default data limit: ${error}`); this.appRoot.showError(this.appRoot.localize('error-set-data-limit')); - serverView.accessKeyDataLimit = dataLimitToDisplayDataAmount( - previousLimit || await computeDefaultAccessKeyDataLimit(this.selectedServer)); - serverView.isAccessKeyDataLimitEnabled = !!previousLimit; + const defaultLimit = previousLimit || await computeDefaultDataLimit(this.selectedServer); + serverView.defaultDataLimitBytes = defaultLimit?.bytes; + serverView.isDefaultDataLimitEnabled = !!previousLimit; } } - private async removeAccessKeyDataLimit() { + private async removeDefaultDataLimit() { const serverView = this.appRoot.getServerView(this.appRoot.selectedServerId); + const previousLimit = this.selectedServer.getDefaultDataLimit(); try { - await this.selectedServer.removeAccessKeyDataLimit(); + await this.selectedServer.removeDefaultDataLimit(); + serverView.isDefaultDataLimitEnabled = false; this.appRoot.showNotification(this.appRoot.localize('saved')); this.refreshTransferStats(this.selectedServer, serverView); } catch (error) { - console.error(`Failed to remove access key data limit: ${error}`); + console.error(`Failed to remove server default data limit: ${error}`); this.appRoot.showError(this.appRoot.localize('error-remove-data-limit')); - serverView.isAccessKeyDataLimitEnabled = true; + serverView.isDefaultDataLimitEnabled = !!previousLimit; + } + } + + private openPerKeyDataLimitDialog(event: CustomEvent<{ + keyId: string, + keyDataLimitBytes: number|undefined, + keyName: string, + serverId: string, + defaultDataLimitBytes: number|undefined + }>) { + const detail = event.detail; + const onDataLimitSet = this.savePerKeyDataLimit.bind(this, detail.serverId, detail.keyId); + const onDataLimitRemoved = this.removePerKeyDataLimit.bind(this, detail.serverId, detail.keyId); + const activeDataLimitBytes = detail.keyDataLimitBytes ?? detail.defaultDataLimitBytes; + this.appRoot.openPerKeyDataLimitDialog( + detail.keyName, activeDataLimitBytes, onDataLimitSet, onDataLimitRemoved); + } + + private async savePerKeyDataLimit(serverId: string, keyId: string, dataLimitBytes: number): + Promise { + this.appRoot.showNotification(this.appRoot.localize('saving')); + const server = this.idServerMap.get(serverId); + const serverView = this.appRoot.getServerView(server.getId()); + try { + await server.setAccessKeyDataLimit(keyId, {bytes: dataLimitBytes}); + this.refreshTransferStats(server, serverView); + this.appRoot.showNotification(this.appRoot.localize('saved')); + return true; + } catch (error) { + console.error(`Failed to set data limit for access key ${keyId}: ${error}`); + this.appRoot.showError(this.appRoot.localize('error-set-per-key-limit')); + return false; + } + } + + private async removePerKeyDataLimit(serverId: string, keyId: string): Promise { + this.appRoot.showNotification(this.appRoot.localize('saving')); + const server = this.idServerMap.get(serverId); + const serverView = this.appRoot.getServerView(server.getId()); + try { + await server.removeAccessKeyDataLimit(keyId); + this.refreshTransferStats(server, serverView); + this.appRoot.showNotification(this.appRoot.localize('saved')); + return true; + } catch (error) { + console.error(`Failed to remove data limit from access key ${keyId}: ${error}`); + this.appRoot.showError(this.appRoot.localize('error-remove-per-key-limit')); + return false; } } diff --git a/src/server_manager/web_app/data_formatting.spec.ts b/src/server_manager/web_app/data_formatting.spec.ts index eb20fd3bf..63d1cdd43 100644 --- a/src/server_manager/web_app/data_formatting.spec.ts +++ b/src/server_manager/web_app/data_formatting.spec.ts @@ -14,32 +14,33 @@ limitations under the License. */ -import * as i18n from './data_formatting'; +import {format} from 'path'; +import * as formatting from './data_formatting'; describe('formatBytesParts', () => { if (process?.versions?.node) { it('doesn\'t run on Node', () => { - expect(() => i18n.formatBytesParts(0, 'en')).toThrow(); + expect(() => formatting.formatBytesParts(0, 'en')).toThrow(); }); } else { it('extracts the unit string and value separately', () => { - const english = i18n.formatBytesParts(0, 'en'); + const english = formatting.formatBytesParts(0, 'en'); expect(english.unit).toEqual('B'); expect(english.value).toEqual('0'); - const korean = i18n.formatBytesParts(2, 'kr'); + const korean = formatting.formatBytesParts(2, 'kr'); expect(korean.unit).toEqual('B'); expect(korean.value).toEqual('2'); - const russian = i18n.formatBytesParts(3000, 'ru'); + const russian = formatting.formatBytesParts(3000, 'ru'); expect(russian.unit).toEqual('кБ'); expect(russian.value).toEqual('3'); - const simplifiedChinese = i18n.formatBytesParts(1.5 * 10 ** 9, 'zh-CN'); + const simplifiedChinese = formatting.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'); + const farsi = formatting.formatBytesParts(133.5 * 10 ** 6, 'fa'); expect(farsi.unit).toEqual('مگابایت'); expect(farsi.value).toEqual('۱۳۳٫۵'); }); @@ -49,19 +50,49 @@ describe('formatBytesParts', () => { describe('formatBytes', () => { if (process?.versions?.node) { it('doesn\'t run on Node', () => { - expect(() => i18n.formatBytes(0, 'en')).toThrow(); + expect(() => formatting.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'); + expect(formatting.formatBytes(2.1, 'zh-TW')).toEqual('2 byte'); + expect(formatting.formatBytes(7.8 * 10 ** 3, 'ar')).toEqual('8 كيلوبايت'); + expect(formatting.formatBytes(1.5 * 10 ** 6, 'tr')).toEqual('1,5 MB'); + expect(formatting.formatBytes(10 * 10 ** 9, 'jp')).toEqual('10 GB'); + expect(formatting.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'); + expect(formatting.formatBytes(10 ** 12, 'en')).toEqual('1 TB'); }); } }); + +function makeDisplayDataAmount(value: number, unit: 'MB'|'GB') { + return {unit, value}; +} + +describe('displayDataAmountToBytes', () => { + it('correctly converts DisplayDataAmounts to byte values', () => { + expect(formatting.displayDataAmountToBytes(makeDisplayDataAmount(1, 'MB'))).toEqual(10 ** 6); + expect(formatting.displayDataAmountToBytes(makeDisplayDataAmount(20, 'GB'))) + .toEqual(2 * 10 ** 10); + expect(formatting.displayDataAmountToBytes(makeDisplayDataAmount(0, 'MB'))).toEqual(0); + }); + it('handles null input', () => { + expect(formatting.displayDataAmountToBytes(null)).toBeNull(); + }); +}); + +describe('bytesToDisplayDataAmount', () => { + it('correctly converts byte values to DisplayDataAmounts', () => { + expect(formatting.bytesToDisplayDataAmount(10 ** 6)).toEqual(makeDisplayDataAmount(1, 'MB')); + expect(formatting.bytesToDisplayDataAmount(3 * 10 ** 9)) + .toEqual(makeDisplayDataAmount(3, 'GB')); + expect(formatting.bytesToDisplayDataAmount(7 * 10 ** 5)) + .toEqual(makeDisplayDataAmount(0, 'MB')); + }); + it('handles null and undefined input', () => { + expect(formatting.bytesToDisplayDataAmount(null)).toBeNull(); + expect(formatting.bytesToDisplayDataAmount(undefined)).toBeNull(); + }); +}); diff --git a/src/server_manager/web_app/data_formatting.ts b/src/server_manager/web_app/data_formatting.ts index 6d96aa1de..a95a9f3a5 100644 --- a/src/server_manager/web_app/data_formatting.ts +++ b/src/server_manager/web_app/data_formatting.ts @@ -105,3 +105,39 @@ export function formatBytes(numBytes: number, language: string): string { const params = getDataFormattingParams(numBytes); return makeDataAmountFormatter(language, params).format(params.value); } + +// TODO(JonathanDCohen222) Differentiate between this type, which is an input data limit, and +// a more general DisplayDataAmount with a string-typed unit and value which respects i18n. +export interface DisplayDataAmount { + unit: 'MB'|'GB'; + value: number; +} + +/** + * @param dataAmount + * @returns The number of bytes represented by dataAmount + */ +export function displayDataAmountToBytes(dataAmount: DisplayDataAmount): number { + if (!dataAmount) { + return null; + } + if (dataAmount.unit === 'GB') { + return dataAmount.value * (10 ** 9); + } else if (dataAmount.unit === 'MB') { + return dataAmount.value * (10 ** 6); + } +} + +/** + * @param bytes + * @returns A DisplayDataAmount representing the number of bytes + */ +export function bytesToDisplayDataAmount(bytes: number): DisplayDataAmount { + if (bytes === null || bytes === undefined) { + return null; + } + if (bytes >= 10 ** 9) { + return {value: Math.floor(bytes / (10 ** 9)), unit: 'GB'}; + } + return {value: Math.floor(bytes / (10 ** 6)), unit: 'MB'}; +} diff --git a/src/server_manager/web_app/gallery_app/main.ts b/src/server_manager/web_app/gallery_app/main.ts index 74024802c..f32b38b09 100644 --- a/src/server_manager/web_app/gallery_app/main.ts +++ b/src/server_manager/web_app/gallery_app/main.ts @@ -18,9 +18,13 @@ import '../ui_components/outline-feedback-dialog'; import '../ui_components/outline-share-dialog'; import '../ui_components/outline-sort-span'; import '../ui_components/outline-survey-dialog'; +import '../ui_components/outline-per-key-data-limit-dialog'; +import '@polymer/paper-checkbox/paper-checkbox'; +import {PaperCheckboxElement} from '@polymer/paper-checkbox/paper-checkbox'; import IntlMessageFormat from 'intl-messageformat'; import {css, customElement, html, LitElement, property} from 'lit-element'; +import {OutlinePerKeyDataLimitDialog} from '../ui_components/outline-per-key-data-limit-dialog'; async function makeLocalize(language: string) { let messages: {[key: string]: string}; @@ -35,7 +39,7 @@ async function makeLocalize(language: string) { for (let i = 0; i < args.length; i += 2) { params[args[i]] = args[i + 1]; } - if (!messages) { + if (!messages || !messages[msgId]) { // Fallback that shows message id and params. return `${msgId}(${JSON.stringify(params, null, " ")})`; } @@ -49,7 +53,9 @@ async function makeLocalize(language: string) { @customElement('outline-test-app') export class TestApp extends LitElement { @property({type: String}) dir = 'ltr'; - @property({type: Function}) localize: Function; + @property({type: Function}) localize: (...args: string[]) => string; + @property({type: Boolean}) savePerKeyDataLimitSuccessful = true; + @property({type: Number}) keyDataLimit: number|undefined; private language = ''; static get styles() { @@ -80,7 +86,7 @@ export class TestApp extends LitElement { return; } this.localize = await makeLocalize(newLanguage); - this.language = newLanguage; + this.language = newLanguage; } // tslint:disable-next-line:no-any @@ -88,18 +94,66 @@ export class TestApp extends LitElement { return this.shadowRoot.querySelector(querySelector); } + private setKeyDataLimit(bytes: number) { + if ((this.select('#perKeyDataLimitSuccessCheckbox') as PaperCheckboxElement).checked) { + this.keyDataLimit = bytes; + console.log(`Per Key Data Limit set to ${bytes} bytes!`); + return true; + } + console.error('Per Key Data Limit failed to be set!'); + return false; + } + + private removeKeyDataLimit() { + if ((this.select('#perKeyDataLimitSuccessCheckbox') as PaperCheckboxElement).checked) { + this.keyDataLimit = undefined; + console.log('Per Key Data Limit Removed!'); + return true; + } + console.error('Per Key Data Limit failed to be removed!'); + return false; + } + render() { return html`

Outline Manager Components Gallery

${this.pageControls} - + +
+

outline-per-key-data-limit-dialog

+ + { + this.savePerKeyDataLimitSuccessful = !this.savePerKeyDataLimitSuccessful; + }} + id="perKeyDataLimitSuccessCheckbox" + >Save Successful + +
+

outline-about-dialog

- +

outline-do-oauth-step

@@ -121,7 +175,7 @@ export class TestApp extends LitElement { .open('', '')}>Open Dialog
- +

outline-sort-icon

{ diff --git a/src/server_manager/web_app/shadowbox_server.ts b/src/server_manager/web_app/shadowbox_server.ts index 22a81ed69..64c042520 100644 --- a/src/server_manager/web_app/shadowbox_server.ts +++ b/src/server_manager/web_app/shadowbox_server.ts @@ -31,6 +31,8 @@ interface ServerConfigJson { portForNewAccessKeys: number; hostnameForAccessKeys: string; version: string; + // This is the server default data limit. We use this instead of defaultDataLimit for API + // backwards compatibility. accessKeyDataLimit?: server.DataLimit; } @@ -87,28 +89,28 @@ export class ShadowboxServer implements server.Server { return this.apiRequest('access-keys/' + accessKeyId, {method: 'DELETE'}); } - async setAccessKeyDataLimit(limit: server.DataLimit): Promise { - console.info(`Setting access key data limit: ${JSON.stringify(limit)}`); + async setDefaultDataLimit(limit: server.DataLimit): Promise { + console.info(`Setting server default data limit: ${JSON.stringify(limit)}`); const requestOptions = { method: 'PUT', headers: new Headers({'Content-Type': 'application/json'}), body: JSON.stringify({limit}) }; - await this.apiRequest(this.getAccessKeyDataLimitPath(), requestOptions); + await this.apiRequest(this.getDefaultDataLimitPath(), requestOptions); this.serverConfig.accessKeyDataLimit = limit; } - async removeAccessKeyDataLimit(): Promise { - console.info(`Removing access key data limit`); - await this.apiRequest(this.getAccessKeyDataLimitPath(), {method: 'DELETE'}); + async removeDefaultDataLimit(): Promise { + console.info(`Removing server default data limit`); + await this.apiRequest(this.getDefaultDataLimitPath(), {method: 'DELETE'}); delete this.serverConfig.accessKeyDataLimit; } - getAccessKeyDataLimit(): server.DataLimit|undefined { + getDefaultDataLimit(): server.DataLimit|undefined { return this.serverConfig.accessKeyDataLimit; } - private getAccessKeyDataLimitPath(): string { + private getDefaultDataLimitPath(): string { const version = this.getVersion(); if (semver.gte(version, '1.4.0')) { // Data limits became a permanent feature in shadowbox v1.4.0. @@ -117,6 +119,21 @@ export class ShadowboxServer implements server.Server { return 'experimental/access-key-data-limit'; } + async setAccessKeyDataLimit(keyId: server.AccessKeyId, limit: server.DataLimit): Promise { + console.info(`Setting data limit of ${limit.bytes} bytes for access key ${keyId}`); + const requestOptions = { + method: 'PUT', + headers: new Headers({'Content-Type': 'application/json'}), + body: JSON.stringify({limit}) + }; + await this.apiRequest(`access-keys/${keyId}/data-limit`, requestOptions); + } + + async removeAccessKeyDataLimit(keyId: server.AccessKeyId): Promise { + console.info(`Removing data limit from access key ${keyId}`); + await this.apiRequest(`access-keys/${keyId}/data-limit`, {method: 'DELETE'}); + } + async getDataUsage(): Promise { const jsonResponse = await this.apiRequest('metrics/transfer'); const usageMap = new Map(); 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 58a2e9eea..bab21d846 100644 --- a/src/server_manager/web_app/ui_components/app-root.js +++ b/src/server_manager/web_app/ui_components/app-root.js @@ -32,6 +32,7 @@ import './outline-do-oauth-step.js'; import './outline-feedback-dialog.js'; import './outline-survey-dialog.js'; import './outline-intro-step.js'; +import './outline-per-key-data-limit-dialog'; import './outline-language-picker.js'; import './outline-manual-server-entry.js'; import './outline-modal-dialog.js'; @@ -43,6 +44,8 @@ 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 {displayDataAmountToBytes} from '../data_formatting'; + import {ServerView} from './outline-server-view.js'; const TOS_ACK_LOCAL_STORAGE_KEY = 'tos-ack'; @@ -404,7 +407,7 @@ export class AppRoot extends mixinBehaviors [[localize('nav-privacy')]] [[localize('nav-terms')]] [[localize('nav-licenses')]] -
+ @@ -469,6 +472,7 @@ export class AppRoot extends mixinBehaviors + @@ -811,6 +815,15 @@ export class AppRoot extends mixinBehaviors this.$.shareDialog.open(accessKey, s3Url); } + /** + * @param accessKey The DisplayAccessKey for the dialog to work on + */ + openPerKeyDataLimitDialog(keyName, activeDataLimitBytes, onDataLimitSet, onDataLimitRemoved) { + // attach listeners here + this.$.perKeyDataLimitDialog.open( + keyName, activeDataLimitBytes, onDataLimitSet, onDataLimitRemoved); + } + openGetConnectedDialog(/** @type {string} */ inviteUrl) { const dialog = this.$.getConnectedDialog; if (dialog.children.length > 1) { diff --git a/src/server_manager/web_app/ui_components/outline-per-key-data-limit-dialog.ts b/src/server_manager/web_app/ui_components/outline-per-key-data-limit-dialog.ts new file mode 100644 index 000000000..4c38ee1ef --- /dev/null +++ b/src/server_manager/web_app/ui_components/outline-per-key-data-limit-dialog.ts @@ -0,0 +1,358 @@ +/* + 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 '@polymer/paper-button/paper-button'; +import '@polymer/paper-checkbox/paper-checkbox'; +import '@polymer/paper-dialog/paper-dialog'; +import '@polymer/paper-dropdown-menu/paper-dropdown-menu'; +import '@polymer/paper-input/paper-input'; +import '@polymer/paper-item/paper-item'; +import '@polymer/paper-listbox/paper-listbox'; + +import {PaperDialogElement} from '@polymer/paper-dialog/paper-dialog'; +import {PaperInputElement} from '@polymer/paper-input/paper-input'; +import {PaperListboxElement} from '@polymer/paper-listbox/paper-listbox'; +import {css, customElement, html, internalProperty, LitElement, property} from 'lit-element'; + +import {bytesToDisplayDataAmount, DisplayDataAmount, displayDataAmountToBytes, formatBytesParts} from '../data_formatting'; + +import {COMMON_STYLES} from './cloud-install-styles'; + +/** + * A floating window representing settings specific to individual access keys. Its state is + * dynamically set when opened using the open() method instead of with any in-HTML attributes. + * + * This element relies on conceptual separation of the data limit as input by the user, the data + * limit of the UI key, and the actual data limit as saved on the server. App controls the UI data + * limit and the request to the server, and the display key in the element is never itself changed. + */ +@customElement('outline-per-key-data-limit-dialog') +export class OutlinePerKeyDataLimitDialog extends LitElement { + static get styles() { + return [ + COMMON_STYLES, + css` + #container { + width: 100%; + display: flex; + flex-flow: column nowrap; + } + + #dataLimitIcon { + /* Split the padding evenly between the icon and the section to be bidirectional. */ + padding: 0 12px; + } + + #headerSection { + display: flex; + flex-direction: row; + padding: 0 12px; + } + + #headerSection h3 { + font-size: 18px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + line-height: 24px; + } + + #menuSection { + flex: 1; + padding: 0 78px; + margin-top: 10px; + } + + #buttonsSection { + margin-top: 10px; + display: flex; + flex-direction: row-reverse; + } + + paper-button { + display: flex; + height: 36px; + text-align: center; + } + + #save { + background-color: var(--primary-green); + color: #fff; + } + + #save[disabled] { + color: var(--dark-gray); + background-color: rgba(0, 0, 0, 0.13); + } + + #menu { + display: flex; + flex-flow: row nowrap; + } + + #unitsDropdown { + width: 50px; + padding: 0 10px; + } + + paper-checkbox { + /* We want the ink to be the color we're going to, not coming from */ + --paper-checkbox-checked-color: var(--primary-green); + --paper-checkbox-checked-ink-color: var(--dark-gray); + --paper-checkbox-unchecked-color: var(--dark-gray); + --paper-checkbox-unchecked-ink-color: var(--primary-green); + } + + paper-listbox paper-item:hover { + cursor: pointer; + background-color: var(--background-contrast-color); + color: #fff; + } + `, + ]; + } + + /** + * @member _keyName The displayed name of the UI access key representing the key we're working on. + */ + @internalProperty() _keyName = ''; + /** + * @member _activeDataLimitBytes The data limit, if it exists, on the access key we're working on. + */ + @internalProperty() _initialDataLimitBytes: number = undefined; + /** + * @member _showDataLimit Whether the menu for inputting the data limit should be shown. + * Controlled by the checkbox. + */ + @internalProperty() _showDataLimit = false; + /** + * @member _enableSave Whether the save button is enabled. Controlled by the validator on the + * input. + */ + @internalProperty() _enableSave = false; + + /** + * @member language The ISO 3166-1 alpha-2 language code used for i18n. + */ + @property({type: String}) language = ''; + @property({type: Function}) localize: Function; + + private _onDataLimitSet?: OnSetDataLimitHandler; + private _onDataLimitRemoved?: OnRemoveDataLimitHandler; + + render() { + return html` + + +
+ +

${this.localize('per-key-data-limit-dialog-title', 'keyName', this._keyName)}

+
+ +
+ ${ + this.localize('save')} + ${this.localize('cancel')} +
+
+ `; + } + + private renderDataLimit() { + return html` + + `; + } + + private _queryAs(selector: string): T { + return this.shadowRoot.querySelector(selector) as T; + } + + private get _input(): PaperInputElement { + return this._queryAs('#dataLimitInput'); + } + + private _dataLimitValue() { + return Number(this._input?.value) ?? 0; + } + + private _dataLimitUnit(): 'GB'|'MB' { + return this._queryAs('#unitsListbox').selected as 'GB' | 'MB'; + } + + private _getInternationalizedUnit(bytes: number) { + return formatBytesParts(bytes, this.language).unit; + } + + private _initialUnit(): 'GB'|'MB' { + return bytesToDisplayDataAmount(this._initialDataLimitBytes)?.unit || 'GB'; + } + + private _initialValue() { + return bytesToDisplayDataAmount(this._initialDataLimitBytes)?.value.toString() || ''; + } + + private async _setCustomLimitTapped() { + this._showDataLimit = !this._showDataLimit; + if (this._showDataLimit) { + await this.updateComplete; + this._input?.focus(); + } + } + + private _setSaveButtonDisabledState() { + this._enableSave = !this._input?.invalid ?? false; + } + + private async _onSaveButtonTapped() { + const change = this._dataLimitChange(); + if (change === Change.UNCHANGED) { + return; + } + const result = change === Change.SET ? + await this._onDataLimitSet(displayDataAmountToBytes(this.inputDataLimit())) : + await this._onDataLimitRemoved(); + if (result) { + this.close(); + } + } + + /** + * Calculates what type of change, or none at all, the user made. + */ + private _dataLimitChange(): Change { + if (this._showDataLimit) { + if (this._initialDataLimitBytes === undefined) { + // The user must have clicked the checkbox and input a limit. + return Change.SET; + } + const inputLimit = displayDataAmountToBytes(this.inputDataLimit()); + if (inputLimit !== this._initialDataLimitBytes) { + return Change.SET; + } + return Change.UNCHANGED; + } + if (this._initialDataLimitBytes !== undefined) { + // The user must have unchecked the checkbox. + return Change.REMOVED; + } + return Change.UNCHANGED; + } + + /** + * The current data limit as input by the user, but not necessarily as saved. + */ + public inputDataLimit(): DisplayDataAmount { + return this._showDataLimit ? {unit: this._dataLimitUnit(), value: this._dataLimitValue()} : + null; + } + + /** + * Opens the dialog to display data limit information about the given key. + * + * @param keyName - The displayed name of the access key. + * @param keyDataLimit - The display data limit of the access key, or undefined. + * @param serverDefaultLimit - The default data limit for the server, or undefined if there is + * none. + * @param language - The app's current language + * @param onDataLimitSet - Callback for when a data limit is imposed. Must return whether or not + * the data limit was set successfully. Must not throw or change the dialog's state. + * @param onDataLimitRemoved - Callback for when a data limit is removed. Must return whether or + * not the data limit was removed successfully. Must not throw or change the dialog's state. + */ + public open( + keyName: string, keyLimitBytes: number, onDataLimitSet: OnSetDataLimitHandler, + onDataLimitRemoved: OnRemoveDataLimitHandler) { + this._keyName = keyName; + this._initialDataLimitBytes = keyLimitBytes; + this._showDataLimit = this._initialDataLimitBytes !== undefined; + this._onDataLimitSet = onDataLimitSet; + this._onDataLimitRemoved = onDataLimitRemoved; + + this._queryAs('#unitsListbox')?.select(this._initialUnit()); + this._setSaveButtonDisabledState(); + this._queryAs('#container').open(); + } + + private _onDialogOpenedChanged(openedChanged: CustomEvent<{value: boolean}>) { + const dialogWasClosed = !openedChanged.detail.value; + if (dialogWasClosed) { + delete this._onDataLimitSet; + delete this._onDataLimitRemoved; + } + } + + /** + * Closes the dialog. + */ + public close() { + this._queryAs('#container').close(); + } +} + +enum Change { + SET, // A data limit was added or the existing data limit changed + REMOVED, // The data limit for the key was removed + UNCHANGED, // No functional change happened. +} + +type OnSetDataLimitHandler = (dataLimitBytes: number) => Promise; +type OnRemoveDataLimitHandler = () => Promise; 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 3dd5ab972..4438da5d3 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 @@ -192,12 +192,12 @@ Polymer({ - + -
+
@@ -208,7 +208,7 @@ Polymer({ - + [[localize('enabled')]] [[localize('disabled')]] @@ -218,12 +218,12 @@ Polymer({

-
- +
+ - - [[_getInternationalizedUnit(1000000, language)]] - [[_getInternationalizedUnit(1000000000, language)]] + + [[_getInternationalizedUnit(1000000, language)]] + [[_getInternationalizedUnit(1000000000, language)]]
@@ -264,16 +264,16 @@ Polymer({ metricsEnabled: Boolean, // Initialize to null so we can use the hidden attribute, which does not work well with // undefined values. - serverId: {type: String, value: null}, + metricsId: {type: String, value: null}, serverHostname: {type: String, value: null}, serverManagementApiUrl: {type: String, value: null}, serverPortForNewAccessKeys: {type: Number, value: null}, serverVersion: {type: String, value: null}, isAccessKeyPortEditable: {type: Boolean, value: false}, - isAccessKeyDataLimitEnabled: {type: Boolean, notify: true}, - accessKeyDataLimit: {type: Object, value: null}, // type: app.DisplayDataAmount - supportsAccessKeyDataLimit: - {type: Boolean, value: false}, // Whether the server supports data limits. + isDefaultDataLimitEnabled: {type: Boolean, notify: true}, + defaultDataLimit: {type: Object, value: null}, // type: app.DisplayDataAmount + supportsDefaultDataLimit: + {type: Boolean, value: false}, // Whether the server supports default data limits. showFeatureMetricsDisclaimer: {type: Boolean, value: false}, isHostnameEditable: {type: Boolean, value: true}, serverCreationDate: {type: Date, value: '1970-01-01T00:00:00.000Z'}, @@ -317,42 +317,42 @@ Polymer({ this.fire(metricsSignal); }, - _accessKeyDataLimitEnabledChanged: function(e) { - const wasDataLimitEnabled = this.isAccessKeyDataLimitEnabled; + _defaultDataLimitEnabledChanged: function(e) { + const wasDataLimitEnabled = this.isDefaultDataLimitEnabled; const isDataLimitEnabled = e.detail.value === 'enabled'; if (isDataLimitEnabled === undefined || wasDataLimitEnabled === undefined) { return; } else if (isDataLimitEnabled === wasDataLimitEnabled) { return; } - this.isAccessKeyDataLimitEnabled = isDataLimitEnabled; + this.isDefaultDataLimitEnabled = isDataLimitEnabled; if (isDataLimitEnabled) { - this._requestSetAccessKeyDataLimit(); + this._requestSetDefaultDataLimit(); } else { - this.fire('RemoveAccessKeyDataLimitRequested'); + this.fire('RemoveDefaultDataLimitRequested'); } }, - _handleAccessKeyDataLimitInputKeyDown: function(event) { + _handleDefaultDataLimitInputKeyDown: function(event) { if (event.key === 'Escape') { - this.$.accessKeyDataLimitInput.value = this.accessKeyDataLimit.value; - this.$.accessKeyDataLimitInput.blur(); + this.$.defaultDataLimitInput.value = this.defaultDataLimit.value; + this.$.defaultDataLimitInput.blur(); } else if (event.key === 'Enter') { - this.$.accessKeyDataLimitInput.blur(); + this.$.defaultDataLimitInput.blur(); } }, - _requestSetAccessKeyDataLimit: function() { - if (this.$.accessKeyDataLimitInput.invalid) { + _requestSetDefaultDataLimit: function() { + if (this.$.defaultDataLimitInput.invalid) { return; } - const value = Number(this.$.accessKeyDataLimitInput.value); - const unit = this.$.accessKeyDataLimitUnits.selected; - this.fire('SetAccessKeyDataLimitRequested', {limit: {value, unit}}); + const value = Number(this.$.defaultDataLimitInput.value); + const unit = this.$.defaultDataLimitUnits.selected; + this.fire('SetDefaultDataLimitRequested', {limit: {value, unit}}); }, - _computeDataLimitsEnabledName: function(isAccessKeyDataLimitEnabled) { - return isAccessKeyDataLimitEnabled ? 'enabled' : 'disabled'; + _computeDataLimitsEnabledName: function(isDefaultDataLimitEnabled) { + return isDefaultDataLimitEnabled ? 'enabled' : 'disabled'; }, _validatePort: function(value) { 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 03fc003f0..bf71b34a3 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,9 +36,11 @@ import './outline-sort-span.js'; import {html, PolymerElement} from '@polymer/polymer'; import {DirMixin} from '@polymer/polymer/lib/mixins/dir-mixin.js'; -import * as i18n from '../data_formatting'; +import * as formatting from '../data_formatting'; -const MY_CONNECTION_USER_ID = '0'; +export const MY_CONNECTION_USER_ID = '0'; + +const progressBarMaxWidthPx = 72; // Makes an CustomEvent that bubbles up beyond the shadow root. function makePublicEvent(eventName, detail) { @@ -58,6 +60,15 @@ function compare(a, b) { return 0; } +/** + * Allows using an optional number as a boolean value without 0 being falsey. + * @param {number=} x + * @returns {number=} True if x is neither null nor undefined + */ +function exists(x) { + return (x !== null && x !== undefined); +} + /** * An access key to be displayed * @typedef {Object} DisplayAccessKey @@ -66,13 +77,8 @@ function compare(a, b) { * @prop {string} name * @prop {string} accessUrl * @prop {number} transferredBytes - * @prop {number} relativeTraffic - */ - -/** - * @typedef {Object} DisplayDataAmount - * @prop {'MB'|'GB'} unit - * @prop {number} value + * @prop {number=} dataLimitBytes The data limit assigned to the key if it exists. + * @prop {DisplayDataAmount=} dataLimit The data limit assigned to the key if it exists. */ export class ServerView extends DirMixin(PolymerElement) { @@ -234,7 +240,7 @@ export class ServerView extends DirMixin(PolymerElement) { } .measurement-container { display: flex; - flex: 3; + flex: 4; align-items: center; } .measurement-container paper-progress { @@ -257,7 +263,7 @@ export class ServerView extends DirMixin(PolymerElement) { } .measurement { /* Space the usage bars evenly */ - min-width: 11ch; + width: 19ch; /* We don't want numbers separated from their units */ white-space: nowrap; font-size: 14px; @@ -396,7 +402,7 @@ export class ServerView extends DirMixin(PolymerElement) {
- +
${this.unreachableViewTemplate}
${this.managementViewTemplate}
@@ -430,7 +436,7 @@ export class ServerView extends DirMixin(PolymerElement) {

[[serverName]]

-
+

[[localize('server-unreachable')]]

@@ -470,7 +476,7 @@ export class ServerView extends DirMixin(PolymerElement) { [[localize('server-connections')]] [[localize('server-settings')]] -
+
@@ -527,17 +533,23 @@ export class ServerView extends DirMixin(PolymerElement) {
- [[_formatBytesTransferred(myConnection.transferredBytes, language, "...")]] - - - [[_getDataLimitsUsageString(myConnection)]] + + [[_formatBytesTransferred(myConnection.transferredBytes, language, "...")]] + / + [[_formatDataLimitForKey(myConnection, language)]] + + + + [[_getDataLimitsUsageString(myConnection, language)]] - + + +
@@ -550,10 +562,14 @@ export class ServerView extends DirMixin(PolymerElement) { - [[_formatBytesTransferred(item.transferredBytes, language, "...")]] - - - [[_getDataLimitsUsageString(item)]] + + [[_formatBytesTransferred(item.transferredBytes, language, "...")]] + / + [[_formatDataLimitForKey(item, language)]] + + + + [[_getDataLimitsUsageString(item, language)]] @@ -570,6 +586,9 @@ export class ServerView extends DirMixin(PolymerElement) { [[localize('remove')]] + + [[localize('data-limit')]] + @@ -587,7 +606,7 @@ export class ServerView extends DirMixin(PolymerElement) {
- +
`; @@ -599,6 +618,7 @@ export class ServerView extends DirMixin(PolymerElement) { static get properties() { return { + metricsId: String, serverId: String, serverName: String, serverHostname: String, @@ -608,16 +628,17 @@ export class ServerView extends DirMixin(PolymerElement) { serverPortForNewAccessKeys: Number, isAccessKeyPortEditable: {type: Boolean}, serverCreationDate: {type: Date}, - serverLocationId: String, - accessKeyDataLimit: {type: Object}, - isAccessKeyDataLimitEnabled: {type: Boolean}, - supportsAccessKeyDataLimit: {type: Boolean}, + serverLocation: String, + defaultDataLimitBytes: Number, + isDefaultDataLimitEnabled: {type: Boolean}, + supportsDefaultDataLimit: {type: Boolean}, showFeatureMetricsDisclaimer: {type: Boolean}, isServerManaged: Boolean, isServerReachable: Boolean, retryDisplayingServer: Function, myConnection: Object, totalInboundBytes: Number, + baselineDataTransfer: Number, accessKeyRows: {type: Array}, hasNonAdminAccessKeys: Boolean, metricsEnabled: Boolean, @@ -647,6 +668,7 @@ export class ServerView extends DirMixin(PolymerElement) { constructor() { super(); this.serverId = ''; + this.metricsId = ''; this.serverName = ''; this.serverHostname = ''; this.serverVersion = ''; @@ -657,11 +679,12 @@ export class ServerView extends DirMixin(PolymerElement) { this.isAccessKeyPortEditable = false; this.serverCreationDate = new Date(0); this.serverLocationId = ''; - /** @type {DisplayDataAmount} */ - this.accessKeyDataLimit = null; - this.isAccessKeyDataLimitEnabled = false; - /** Whether the server supports data limits. */ - this.supportsAccessKeyDataLimit = false; + /** @type {number} */ + this.defaultDataLimitBytes = null; + this.isDefaultDataLimitEnabled = false; + this.hasPerKeyDataLimitDialog = false; + /** Whether the server supports default data limits. */ + this.supportsDefaultDataLimit = false; this.showFeatureMetricsDisclaimer = false; this.isServerManaged = false; this.isServerReachable = false; @@ -673,10 +696,17 @@ export class ServerView extends DirMixin(PolymerElement) { /** * 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} + * + * TODO(JonathanDCohen) Refactor out special casing for myConnection. It exists as a separate + * item in the view even though it's also in accessKeyRows. We can have the special casing + * be in display only, so we can just use accessKeyRows[0] and not have extra logic when it's + * not needed. + * @type {DisplayAccessKey} */ this.myConnection = null; this.totalInboundBytes = 0; + /** The number to which access key transfer amounts are compared for progress bar display */ + this.baselineDataTransfer = Number.POSITIVE_INFINITY; /** @type {DisplayAccessKey[]} */ this.accessKeyRows = []; this.hasNonAdminAccessKeys = false; @@ -725,20 +755,16 @@ export class ServerView extends DirMixin(PolymerElement) { } } - setServerTransferredData(totalBytes) { - this.totalInboundBytes = totalBytes; - } - updateAccessKeyRow(accessKeyId, fields) { let newAccessKeyRow; if (accessKeyId === MY_CONNECTION_USER_ID) { newAccessKeyRow = Object.assign({}, this.get('myConnection'), fields); this.set('myConnection', newAccessKeyRow); } - 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); + for (let accessKeyRowIndex in this.accessKeyRows) { + if (this.accessKeyRows[accessKeyRowIndex].id === accessKeyId) { + newAccessKeyRow = Object.assign({}, this.get(['accessKeyRows', accessKeyRowIndex]), fields); + this.set(['accessKeyRows', accessKeyRowIndex], newAccessKeyRow); return; } } @@ -760,6 +786,16 @@ export class ServerView extends DirMixin(PolymerElement) { return this._showHelpBubble('dataLimitsHelpBubble', 'settingsTab', 'up', 'right'); } + /** + * Returns the UI access key with the given ID. + * @param {server.accessKeyId} id The id of the key to find + * @returns {DisplayAccessKey} The displayed UI key with the given id. + */ + findUiKey(id) { + return id === MY_CONNECTION_USER_ID ? this.myConnection : + this.accessKeyRows.find(key => key.id === id); + } + _closeAddAccessKeyHelpBubble() { this.$.addAccessKeyHelpBubble.hide(); } @@ -818,6 +854,21 @@ export class ServerView extends DirMixin(PolymerElement) { })); } + _handleShowPerKeyDataLimitDialogPressed(event) { + // TODO(cohenjon) change to optional chaining when we upgrade to Electron > >= 8 + const accessKey = (event.model && event.model.item) || this.myConnection; + const keyId = accessKey.id; + const keyDataLimitBytes = accessKey.dataLimitBytes; + const keyName = accessKey === this.myConnection ? this.localize('server-my-access-key') : + accessKey.name || accessKey.placeholderName; + const defaultDataLimitBytes = + this.isDefaultDataLimitEnabled ? this.defaultDataLimitBytes : undefined; + const serverId = this.serverId; + this.dispatchEvent(makePublicEvent( + 'OpenPerKeyDataLimitDialogRequested', + {keyId, keyDataLimitBytes, keyName, serverId, defaultDataLimitBytes})); + } + _handleRenameAccessKeyPressed(event) { const input = this.$.accessKeysContainer.querySelectorAll( '.access-key-row .access-key-container > input')[event.model.index]; @@ -844,12 +895,28 @@ export class ServerView extends DirMixin(PolymerElement) { this.dispatchEvent(makePublicEvent('RemoveAccessKeyRequested', {accessKeyId: accessKey.id})); } + _formatDataLimitForKey(key, language) { + return this._formatDisplayDataLimit(this._activeDataLimitForKey(key), language) + } + + _computeDisplayDataLimit(/** @param {number=} */ limit) { + return formatting.bytesToDisplayDataAmount(limit); + } + + /** + * @param {number=} limit The data limit in bytes + * @param {string} language The 2-letter ISO language code to format for. + */ + _formatDisplayDataLimit(limit, language) { + return exists(limit) ? formatting.formatBytes(limit, language) : this.localize('no-data-limit'); + } + _formatInboundBytesUnit(totalBytes, language) { // This happens during app startup before we set the language if (!language) { return ''; } - return i18n.formatBytesParts(totalBytes, language).unit; + return formatting.formatBytesParts(totalBytes, language).unit; } _formatInboundBytesValue(totalBytes, language) { @@ -857,22 +924,7 @@ export class ServerView extends DirMixin(PolymerElement) { if (!language) { return ''; } - return i18n.formatBytesParts(totalBytes, language).value; - } - - updateAccessKeyRow(accessKeyId, fields) { - let newAccessKeyRow; - if (accessKeyId === MY_CONNECTION_USER_ID) { - newAccessKeyRow = Object.assign({}, this.get('myConnection'), fields); - this.set('myConnection', newAccessKeyRow); - } - 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 formatting.formatBytesParts(totalBytes, language).value; } _formatBytesTransferred(numBytes, language, emptyValue = '') { @@ -881,7 +933,7 @@ export class ServerView extends DirMixin(PolymerElement) { // unused access keys. return emptyValue; } - return i18n.formatBytes(numBytes, language); + return formatting.formatBytes(numBytes, language); } _formatMonthlyCost(monthlyCost, language) { @@ -998,16 +1050,54 @@ export class ServerView extends DirMixin(PolymerElement) { this.dispatchEvent(makePublicEvent('ForgetServerRequested')); } - _computePaperProgressClass(isAccessKeyDataLimitEnabled) { - return isAccessKeyDataLimitEnabled ? 'data-limits' : ''; + /** + * @param {DisplayAccessKey=} accessKey + * @returns {number=} + */ + _activeDataLimitForKey(accessKey) { + if (!accessKey) { + // We're in app startup + return null; + } + + if (exists(accessKey.dataLimitBytes)) { + return accessKey.dataLimitBytes; + } + + return this.isDefaultDataLimitEnabled ? this.defaultDataLimitBytes : null; + } + + _computePaperProgressClass(accessKey) { + return exists(this._activeDataLimitForKey(accessKey)) ? 'data-limits' : ''; + } + + _getRelevantTransferAmountForKey(/** @type{DisplayAccessKey} */ accessKey) { + if (!accessKey) { + // We're in app startup + return null; + } + const activeLimit = this._activeDataLimitForKey(accessKey); + return exists(activeLimit) ? activeLimit : accessKey.transferredBytes; } - _getDataLimitsUsageString(accessKey) { - if (!this.accessKeyDataLimit) { + _computeProgressWidthStyling( + /** @type {DisplayAccessKey} */ accessKey, /** @type {number} */ baselineDataTransfer) { + const relativeTransfer = this._getRelevantTransferAmountForKey(accessKey); + const width = Math.floor(progressBarMaxWidthPx * relativeTransfer / baselineDataTransfer); + // It's important that there's no space in between width and "px" in order for Chrome to accept + // the inline style string. + return `width: ${width}px;`; + } + + _getDataLimitsUsageString(accessKey, UNUSED_language) { + if (!accessKey) { + // We're in app startup return ''; } + + const activeDataLimit = this._activeDataLimitForKey(accessKey); const used = this._formatBytesTransferred(accessKey.transferredBytes, this.language, '0'); - const total = this.accessKeyDataLimit.value + ' ' + this.accessKeyDataLimit.unit; + const total = this._formatDisplayDataLimit(activeDataLimit, this.language); return this.localize('data-limits-usage', 'used', used, 'total', total); } diff --git a/src/shadowbox/server/server_access_key.spec.ts b/src/shadowbox/server/server_access_key.spec.ts index 531cd1c8e..a5801ae06 100644 --- a/src/shadowbox/server/server_access_key.spec.ts +++ b/src/shadowbox/server/server_access_key.spec.ts @@ -227,6 +227,76 @@ describe('ServerAccessKeyRepository', () => { done(); }); + it('removeAccessKeyDataLimit can remove a custom data limit', async (done) => { + const server = new FakeShadowsocksServer(); + const config = new InMemoryConfig({accessKeys: [], nextId: 0}); + const repo = new RepoBuilder().shadowsocksServer(server).keyConfig(config).build(); + const key = await repo.createNewAccessKey(); + await setKeyLimitAndEnforce(repo, key.id, {bytes: 1}); + await expectNoAsyncThrow(repo.removeAccessKeyDataLimit.bind(repo, key.id)); + expect(key.dataLimit).toBeFalsy(); + expect(config.mostRecentWrite.accessKeys[0].dataLimit).not.toBeDefined(); + done(); + }); + + it('removeAccessKeyDataLimit restores a key to the default data limit', async (done) => { + const server = new FakeShadowsocksServer(); + const prometheusClient = new FakePrometheusClient({'0': 500}); + const repo = + new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); + const key = await repo.createNewAccessKey(); + await repo.start(new ManualClock()); + await repo.setDefaultDataLimit({bytes: 0}); + await setKeyLimitAndEnforce(repo, key.id, {bytes: 1000}); + expect(key.isOverDataLimit).toBeFalsy(); + + await removeKeyLimitAndEnforce(repo, key.id); + expect(key.isOverDataLimit).toBeTruthy(); + done(); + }); + + it('setAccessKeyDataLimit can change a key\'s limit status', async (done) => { + const server = new FakeShadowsocksServer(); + const prometheusClient = new FakePrometheusClient({'0': 500}); + const repo = + new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); + await repo.start(new ManualClock()); + const key = await repo.createNewAccessKey(); + await setKeyLimitAndEnforce(repo, key.id, {bytes: 0}); + + expect(key.isOverDataLimit).toBeTruthy(); + let serverKeys = server.getAccessKeys(); + expect(serverKeys.length).toEqual(0); + + await setKeyLimitAndEnforce(repo, key.id, {bytes: 1000}); + + expect(key.isOverDataLimit).toBeFalsy(); + serverKeys = server.getAccessKeys(); + expect(serverKeys.length).toEqual(1); + expect(serverKeys[0].id).toEqual(key.id); + done(); + }); + + it('setAccessKeyDataLimit overrides default data limit', async (done) => { + const server = new FakeShadowsocksServer(); + const prometheusClient = new FakePrometheusClient({'0': 750, '1': 1250}); + const repo = + new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); + await repo.start(new ManualClock()); + const lowerLimitThanDefault = await repo.createNewAccessKey(); + const higherLimitThanDefault = await repo.createNewAccessKey(); + await repo.setDefaultDataLimit({bytes: 1000}); + + expect(lowerLimitThanDefault.isOverDataLimit).toBeFalsy(); + await setKeyLimitAndEnforce(repo, lowerLimitThanDefault.id, {bytes: 500}); + expect(lowerLimitThanDefault.isOverDataLimit).toBeTruthy(); + + expect(higherLimitThanDefault.isOverDataLimit).toBeTruthy(); + await setKeyLimitAndEnforce(repo, higherLimitThanDefault.id, {bytes: 1500}); + expect(higherLimitThanDefault.isOverDataLimit).toBeFalsy(); + done(); + }); + async function removeKeyLimitAndEnforce(repo: ServerAccessKeyRepository, id: AccessKeyId) { repo.removeAccessKeyDataLimit(id); // We enforce asynchronously, in setAccessKeyDataLimit, so explicitly call it here to make sure @@ -234,7 +304,7 @@ describe('ServerAccessKeyRepository', () => { return repo.enforceAccessKeyDataLimits(); } - it('removeAccessKeyDataLimit can remove a custom data limit', async(done) => { + it('removeAccessKeyDataLimit can remove a custom data limit', async (done) => { const server = new FakeShadowsocksServer(); const config = new InMemoryConfig({accessKeys: [], nextId: 0}); const repo = new RepoBuilder().shadowsocksServer(server).keyConfig(config).build(); @@ -245,12 +315,12 @@ describe('ServerAccessKeyRepository', () => { expect(config.mostRecentWrite.accessKeys[0].dataLimit).not.toBeDefined(); done(); }); - - it('removeAccessKeyDataLimit restores a key to the default data limit', async(done) => { + + it('removeAccessKeyDataLimit restores a key to the default data limit', async (done) => { const server = new FakeShadowsocksServer(); const prometheusClient = new FakePrometheusClient({'0': 500}); - const repo = - new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); + const repo = + new RepoBuilder().prometheusClient(prometheusClient).shadowsocksServer(server).build(); const key = await repo.createNewAccessKey(); await repo.start(new ManualClock()); await repo.setDefaultDataLimit({bytes: 0}); @@ -261,7 +331,7 @@ describe('ServerAccessKeyRepository', () => { expect(key.isOverDataLimit).toBeTruthy(); done(); }); - + it('removeAccessKeyDataLimit can restore an over-limit access key', async(done) => { const server = new FakeShadowsocksServer(); const prometheusClient = new FakePrometheusClient({'0': 500}); diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index 53e5c87cb..f96295ec7 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -178,7 +178,6 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { this.saveAccessKeys(); } - setAccessKeyDataLimit(id: AccessKeyId, limit: DataLimit): void { this.getAccessKey(id).dataLimit = limit; this.saveAccessKeys();