diff --git a/server_manager/images/Material-Icons.woff2 b/server_manager/images/Material-Icons.woff2 new file mode 100644 index 00000000000..f1fd22ff1c1 Binary files /dev/null and b/server_manager/images/Material-Icons.woff2 differ diff --git a/server_manager/index.html b/server_manager/index.html index 943ce3c6bc8..a411c1af053 100644 --- a/server_manager/index.html +++ b/server_manager/index.html @@ -27,6 +27,20 @@ http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' outline: data:; connect-src https: 'self'; frame-src https://s3.amazonaws.com/outline-vpn/ ss:" /> + + + Outline Manager diff --git a/server_manager/messages/master_messages.json b/server_manager/messages/master_messages.json index 88d534cd51e..c7eeb4e6f68 100644 --- a/server_manager/messages/master_messages.json +++ b/server_manager/messages/master_messages.json @@ -1051,6 +1051,16 @@ "message": "Access keys", "description": "This string appears within the server view as a header of a table column that displays server access keys." }, + "server_access_keys_tab": { + "message": "Access keys ($KEY_COUNT$)", + "description": "This string is a tab header indicating to the user that they're currently managing their access keys.", + "placeholders": { + "KEY_COUNT": { + "content": "{keyCount}", + "example": "12" + } + } + }, "server_connections": { "message": "Connections", "description": "This string appears within the server view as a header of the section that displays server information and access keys." @@ -1099,6 +1109,22 @@ "message": "Metrics", "description": "This string appears within the server view as a header of the section that displays server metrics." }, + "server_metrics_data_transferred": { + "message": "Data transferred in the last 30 days", + "description": "This string indicates to the user that the metric displayed counts how much data was sent through the server over the last 30 days" + }, + "server_metrics_user_hours": { + "message": "User hours spent on the VPN in the last 30 days", + "description": "This string indicates to the user that the metric displayed counts how many hours users used the VPN over the last 30 days." + }, + "server_metrics_user_hours_unit": { + "message": "hours", + "description": "This string indicates to the user that the metric displayed is in hours." + }, + "server_metrics_average_devices": { + "message": "Average number of devices used in the last 30 days", + "description": "This string indicates to the user that the metric displayed corresponds to the average number of devices that used the VPN over the last 30 days." + }, "server_my_access_key": { "message": "My access key", "description": "This string appears within the server view as the header for the default server access key. This key is meant to be used by the server administrator." diff --git a/server_manager/model/server.ts b/server_manager/model/server.ts index 040908080f7..04a91873b58 100644 --- a/server_manager/model/server.ts +++ b/server_manager/model/server.ts @@ -36,8 +36,11 @@ export interface Server { // Lists the access keys for this server, including the admin. listAccessKeys(): Promise; - // Returns stats for bytes transferred across all access keys of this server. - getDataUsage(): Promise; + // Returns server metrics + getServerMetrics(): Promise<{ + server: ServerMetrics[]; + accessKeys: AccessKeyMetrics[]; + }>; // Adds a new access key to this server. addAccessKey(): Promise; @@ -51,10 +54,10 @@ export interface Server { // 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; + setDefaultDataLimit(limit: Data): Promise; // Returns the server default access key data transfer limit, or undefined if it has not been set. - getDefaultDataLimit(): DataLimit | undefined; + getDefaultDataLimit(): Data | undefined; // 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 @@ -63,10 +66,7 @@ export interface Server { // 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; + setAccessKeyDataLimit(accessKeyId: AccessKeyId, limit: Data): 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. @@ -149,6 +149,7 @@ export interface ManagedServerHost { delete(): Promise; } +// TODO: refactor to the `Data` type, see below export class DataAmount { terabytes: number; } @@ -183,13 +184,31 @@ export interface AccessKey { id: AccessKeyId; name: string; accessUrl: string; - dataLimit?: DataLimit; + dataLimit?: Data; } -export type BytesByAccessKey = Map; - // Data transfer allowance, measured in bytes. // NOTE: Must be kept in sync with the definition in src/shadowbox/access_key.ts. -export interface DataLimit { +export interface Data { readonly bytes: number; } + +export interface Duration { + readonly seconds: number; +} + +export interface ServerMetrics { + location: string; + asn: number; + asOrg: string; + averageDevices: number; + userHours: number; + tunnelTime?: Duration; + dataTransferred?: Data; +} + +export interface AccessKeyMetrics { + accessKeyId: AccessKeyId; + tunnelTime?: Duration; + dataTransferred?: Data; +} diff --git a/server_manager/www/app.spec.ts b/server_manager/www/app.spec.ts index d76c99f499c..ba685f71398 100644 --- a/server_manager/www/app.spec.ts +++ b/server_manager/www/app.spec.ts @@ -19,6 +19,7 @@ import { FakeCloudAccounts, FakeDigitalOceanAccount, FakeManualServerRepository, + FakeManualServer, } from './testing/models'; import {AppRoot} from './ui_components/app-root'; import * as accounts from '../model/accounts'; @@ -110,6 +111,36 @@ describe('App', () => { ); }); + it('uses the metrics endpoint by default', async () => { + expect( + ( + await ( + await new FakeManualServer({ + certSha256: 'cert', + apiUrl: 'api-url', + }) + ).getServerMetrics() + ).server.length + ).toBe(0); + }); + + it('uses the experimental metrics endpoint if present', async () => { + class FakeExperimentalMetricsManualServer extends FakeManualServer { + getSupportedExperimentalUniversalMetricsEndpoint() { + return Promise.resolve(true); + } + } + + expect( + ( + await new FakeExperimentalMetricsManualServer({ + certSha256: 'cert', + apiUrl: 'api-url', + }).getServerMetrics() + ).server.length + ).toBe(1); + }); + it('initially shows the last selected server', async () => { const LAST_DISPLAYED_SERVER_ID = 'fake-manual-server-api-url-1'; const manualServerRepo = new FakeManualServerRepository(); diff --git a/server_manager/www/app.ts b/server_manager/www/app.ts index a5e90a02256..49de3d21a45 100644 --- a/server_manager/www/app.ts +++ b/server_manager/www/app.ts @@ -62,7 +62,7 @@ Sentry.init({ function displayDataAmountToDataLimit( dataAmount: DisplayDataAmount -): server_model.DataLimit | null { +): server_model.Data | null { if (!dataAmount) { return null; } @@ -75,7 +75,7 @@ function displayDataAmountToDataLimit( async function computeDefaultDataLimit( server: server_model.Server, accessKeys?: server_model.AccessKey[] -): Promise { +): Promise { try { // Assume non-managed servers have a data transfer capacity of 1TB. let serverTransferCapacity: server_model.DataAmount = {terabytes: 1}; @@ -970,7 +970,7 @@ export class App { console.error(`Failed to load access keys: ${error}`); this.appRoot.showError(this.appRoot.localize('error-keys-get')); } - this.showTransferStats(server, view); + this.showServerMetrics(server, view); }, 0); } @@ -1035,30 +1035,57 @@ export class App { } } - private async refreshTransferStats( + private async refreshServerMetrics( selectedServer: server_model.Server, serverView: ServerView ) { try { - const usageMap = await selectedServer.getDataUsage(); - const keyTransfers = [...usageMap.values()]; + const serverMetrics = await selectedServer.getServerMetrics(); + + let totalUserHours = 0; + let totalAverageDevices = 0; + for (const {averageDevices, userHours} of serverMetrics.server) { + totalAverageDevices += averageDevices; + totalUserHours += userHours; + } + + serverView.totalUserHours = totalUserHours; + serverView.totalAverageDevices = totalAverageDevices; + let totalInboundBytes = 0; - for (const accessKeyBytes of keyTransfers) { - totalInboundBytes += accessKeyBytes; + for (const {dataTransferred} of serverMetrics.accessKeys) { + if (!dataTransferred) continue; + + totalInboundBytes += dataTransferred.bytes; } + serverView.totalInboundBytes = totalInboundBytes; // Update all the displayed access keys, even if usage didn't change, in case data limits did. + const keyDataTransferMap = serverMetrics.accessKeys.reduce( + (map, {accessKeyId, dataTransferred}) => { + if (dataTransferred) { + map.set(String(accessKeyId), dataTransferred.bytes); + } + return map; + }, + new Map() + ); + 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, + for (const accessKey of await selectedServer.listAccessKeys()) { + serverView.updateAccessKeyRow(accessKey.id, { + transferredBytes: keyDataTransferMap.get(accessKey.id) ?? 0, + dataLimitBytes: accessKey.dataLimit?.bytes, }); - keyTransferMax = Math.max(keyTransferMax, usageMap.get(key.id) ?? 0); - dataLimitMax = Math.max(dataLimitMax, key.dataLimit?.bytes ?? 0); + keyTransferMax = Math.max( + keyTransferMax, + keyDataTransferMap.get(accessKey.id) ?? 0 + ); + dataLimitMax = Math.max(dataLimitMax, accessKey.dataLimit?.bytes ?? 0); } + serverView.baselineDataTransfer = Math.max(keyTransferMax, dataLimitMax); } catch (e) { // Since failures are invisible to users we generally want exceptions here to bubble @@ -1074,11 +1101,11 @@ export class App { } } - private showTransferStats( + private showServerMetrics( selectedServer: server_model.Server, serverView: ServerView ) { - this.refreshTransferStats(selectedServer, serverView); + this.refreshServerMetrics(selectedServer, serverView); // Get transfer stats once per minute for as long as server is selected. const statsRefreshRateMs = 60 * 1000; const intervalId = setInterval(() => { @@ -1087,7 +1114,7 @@ export class App { clearInterval(intervalId); return; } - this.refreshTransferStats(selectedServer, serverView); + this.refreshServerMetrics(selectedServer, serverView); }, statsRefreshRateMs); } @@ -1142,7 +1169,7 @@ export class App { }); } - private async setDefaultDataLimit(limit: server_model.DataLimit) { + private async setDefaultDataLimit(limit: server_model.Data) { if (!limit) { return; } @@ -1158,7 +1185,7 @@ export class App { this.appRoot.showNotification(this.appRoot.localize('saved')); serverView.defaultDataLimitBytes = limit?.bytes; serverView.isDefaultDataLimitEnabled = true; - this.refreshTransferStats(this.selectedServer, serverView); + this.refreshServerMetrics(this.selectedServer, serverView); // Don't display the feature collection disclaimer anymore. serverView.showFeatureMetricsDisclaimer = false; window.localStorage.setItem( @@ -1184,7 +1211,7 @@ export class App { await this.selectedServer.removeDefaultDataLimit(); serverView.isDefaultDataLimitEnabled = false; this.appRoot.showNotification(this.appRoot.localize('saved')); - this.refreshTransferStats(this.selectedServer, serverView); + this.refreshServerMetrics(this.selectedServer, serverView); } catch (error) { console.error(`Failed to remove server default data limit: ${error}`); this.appRoot.showError(this.appRoot.localize('error-remove-data-limit')); @@ -1232,7 +1259,7 @@ export class App { const serverView = await this.appRoot.getServerView(server.getId()); try { await server.setAccessKeyDataLimit(keyId, {bytes: dataLimitBytes}); - this.refreshTransferStats(server, serverView); + this.refreshServerMetrics(server, serverView); this.appRoot.showNotification(this.appRoot.localize('saved')); return true; } catch (error) { @@ -1253,7 +1280,7 @@ export class App { const serverView = await this.appRoot.getServerView(server.getId()); try { await server.removeAccessKeyDataLimit(keyId); - this.refreshTransferStats(server, serverView); + this.refreshServerMetrics(server, serverView); this.appRoot.showNotification(this.appRoot.localize('saved')); return true; } catch (error) { diff --git a/server_manager/www/shadowbox_server.ts b/server_manager/www/shadowbox_server.ts index cdc969ab92c..74c4202409b 100644 --- a/server_manager/www/shadowbox_server.ts +++ b/server_manager/www/shadowbox_server.ts @@ -17,6 +17,9 @@ import * as semver from 'semver'; import * as server from '../model/server'; +const HOUR_IN_SECS = 60 * 60; +const DAY_IN_HOURS = 24; + interface AccessKeyJson { id: string; name: string; @@ -33,7 +36,30 @@ interface ServerConfigJson { version: string; // This is the server default data limit. We use this instead of defaultDataLimit for API // backwards compatibility. - accessKeyDataLimit?: server.DataLimit; + accessKeyDataLimit?: server.Data; +} + +interface MetricsJson { + server: { + location: string; + asn: number; + asOrg: string; + tunnelTime?: { + seconds: number; + }; + dataTransferred?: { + bytes: number; + }; + }[]; + accessKeys: { + accessKeyId: string; + tunnelTime?: { + seconds: number; + }; + dataTransferred?: { + bytes: number; + }; + }[]; } // Byte transfer stats for the past 30 days, including both inbound and outbound. @@ -57,6 +83,8 @@ function makeAccessKeyModel(apiAccessKey: AccessKeyJson): server.AccessKey { export class ShadowboxServer implements server.Server { private api: PathApiClient; private serverConfig: ServerConfigJson; + private _supportedExperimentalUniversalMetricsEndpointCache: boolean | null = + null; constructor(private readonly id: string) {} @@ -105,7 +133,7 @@ export class ShadowboxServer implements server.Server { return this.api.request('access-keys/' + accessKeyId, 'DELETE'); } - async setDefaultDataLimit(limit: server.DataLimit): Promise { + async setDefaultDataLimit(limit: server.Data): Promise { console.info(`Setting server default data limit: ${JSON.stringify(limit)}`); await this.api.requestJson(this.getDefaultDataLimitPath(), 'PUT', { limit, @@ -119,7 +147,7 @@ export class ShadowboxServer implements server.Server { delete this.serverConfig.accessKeyDataLimit; } - getDefaultDataLimit(): server.DataLimit | undefined { + getDefaultDataLimit(): server.Data | undefined { return this.serverConfig.accessKeyDataLimit; } @@ -134,7 +162,7 @@ export class ShadowboxServer implements server.Server { async setAccessKeyDataLimit( keyId: server.AccessKeyId, - limit: server.DataLimit + limit: server.Data ): Promise { console.info( `Setting data limit of ${limit.bytes} bytes for access key ${keyId}` @@ -149,16 +177,59 @@ export class ShadowboxServer implements server.Server { await this.api.request(`access-keys/${keyId}/data-limit`, 'DELETE'); } - async getDataUsage(): Promise { + async getServerMetrics(): Promise<{ + server: server.ServerMetrics[]; + accessKeys: server.AccessKeyMetrics[]; + }> { + if (await this.getSupportedExperimentalUniversalMetricsEndpoint()) { + const timeRangeInDays = 30; + const json = await this.api.request( + `experimental/server/metrics?since=${timeRangeInDays}d` + ); + + return { + server: json.server.map(server => { + const userHours = server.tunnelTime.seconds / HOUR_IN_SECS; + + return { + location: server.location, + asn: server.asn, + asOrg: server.asOrg, + tunnelTime: server.tunnelTime, + dataTransferred: server.dataTransferred, + userHours, + averageDevices: userHours / (timeRangeInDays * DAY_IN_HOURS), + }; + }), + accessKeys: json.accessKeys.map(key => ({ + accessKeyId: key.accessKeyId, + tunnelTime: key.tunnelTime, + dataTransferred: key.dataTransferred, + })), + }; + } + + const result: { + server: server.ServerMetrics[]; + accessKeys: server.AccessKeyMetrics[]; + } = { + server: [], + accessKeys: [], + }; + const jsonResponse = await this.api.request('metrics/transfer'); - const usageMap = new Map(); + for (const [accessKeyId, bytes] of Object.entries( jsonResponse.bytesTransferredByUserId )) { - usageMap.set(accessKeyId, bytes ?? 0); + result.accessKeys.push({ + accessKeyId, + dataTransferred: {bytes}, + }); } - return usageMap; + + return result; } getName(): string { @@ -260,8 +331,34 @@ export class ShadowboxServer implements server.Server { return await this.api.request('server'); } + private async getSupportedExperimentalUniversalMetricsEndpoint(): Promise { + if (this._supportedExperimentalUniversalMetricsEndpointCache !== null) { + return this._supportedExperimentalUniversalMetricsEndpointCache; + } + + if (!this.api) { + return false; + } + + try { + await this.api.request( + 'experimental/server/metrics?since=30d' + ); + return (this._supportedExperimentalUniversalMetricsEndpointCache = true); + } catch (error) { + // endpoint is not defined, keep set to false + if (error.response?.status !== 404) { + return false; + } + } + } + protected setManagementApi(api: PathApiClient): void { this.api = api; + + // re-populate the supported endpoint cache + this._supportedExperimentalUniversalMetricsEndpointCache = null; + this.getSupportedExperimentalUniversalMetricsEndpoint(); } getManagementApiUrl(): string { diff --git a/server_manager/www/testing/models.ts b/server_manager/www/testing/models.ts index 95db487b124..7b03d6eedf7 100644 --- a/server_manager/www/testing/models.ts +++ b/server_manager/www/testing/models.ts @@ -168,6 +168,19 @@ export class FakeServer implements server.Server { getDataUsage() { return Promise.resolve(new Map()); } + getServerMetrics(): Promise<{ + server: server.ServerMetrics[]; + accessKeys: server.AccessKeyMetrics[]; + }> { + return Promise.reject( + new Error('FakeServer.getServerMetrics not implemented') + ); + } + getSupportedExperimentalUniversalMetricsEndpoint(): Promise { + return Promise.reject( + new Error('FakeServer.getSupportedExperimentalEndpoints not implemented') + ); + } addAccessKey() { const accessKey = { id: Math.floor(Math.random()).toString(), @@ -206,7 +219,7 @@ export class FakeServer implements server.Server { } setAccessKeyDataLimit( _accessKeyId: string, - _limit: server.DataLimit + _limit: server.Data ): Promise { return Promise.reject( new Error('FakeServer.setAccessKeyDataLimit not implemented') @@ -217,7 +230,7 @@ export class FakeServer implements server.Server { new Error('FakeServer.removeAccessKeyDataLimit not implemented') ); } - setDefaultDataLimit(_limit: server.DataLimit): Promise { + setDefaultDataLimit(_limit: server.Data): Promise { return Promise.reject( new Error('FakeServer.setDefaultDataLimit not implemented') ); @@ -225,7 +238,7 @@ export class FakeServer implements server.Server { removeDefaultDataLimit(): Promise { return Promise.resolve(); } - getDefaultDataLimit(): server.DataLimit | undefined { + getDefaultDataLimit(): server.Data | undefined { return undefined; } } @@ -246,6 +259,41 @@ export class FakeManualServer getCertificateFingerprint() { return this.manualServerConfig.certSha256; } + getSupportedExperimentalUniversalMetricsEndpoint(): Promise { + return Promise.resolve(null); + } + async getServerMetrics(): Promise<{ + server: server.ServerMetrics[]; + accessKeys: server.AccessKeyMetrics[]; + }> { + if (await this.getSupportedExperimentalUniversalMetricsEndpoint()) { + return { + server: [ + { + location: 'US', + asn: 10000, + asOrg: 'Fake AS', + userHours: 0, + averageDevices: 0, + }, + ], + accessKeys: [ + { + accessKeyId: '0', + }, + ], + }; + } + + return { + server: [], + accessKeys: [ + { + accessKeyId: '0', + }, + ], + }; + } } export class FakeManualServerRepository diff --git a/server_manager/www/ui_components/outline-server-view.ts b/server_manager/www/ui_components/outline-server-view.ts index 99af7b1ea06..1e7b03e55a1 100644 --- a/server_manager/www/ui_components/outline-server-view.ts +++ b/server_manager/www/ui_components/outline-server-view.ts @@ -33,6 +33,8 @@ import './outline-server-progress-step'; import './outline-server-settings'; import './outline-share-dialog'; import './outline-sort-span'; +import '../views/server_view/server_stat_grid'; +import '../views/server_view/server_stat_card'; import {html, PolymerElement} from '@polymer/polymer'; import type {PolymerElementProperties} from '@polymer/polymer/interfaces'; import type {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat'; @@ -394,6 +396,16 @@ export class ServerView extends DirMixin(PolymerElement) { .flex-1 { flex: 1; } + + div[name='metrics'] { + margin-top: 15px; + } + + :host { + --server-stat-card-background: var(--background-contrast-color); + --server-stat-card-foreground: var(--medium-gray); + --server-stat-card-highlight: var(--light-gray); + } /* Mirror icons */ :host(:dir(rtl)) iron-icon, :host(:dir(rtl)) .share-button, @@ -537,10 +549,15 @@ export class ServerView extends DirMixin(PolymerElement) { attr-for-selected="name" noink="" > - [[localize('server-connections')]] +
-
-
- -
-

- [[_formatInboundBytesValue(totalInboundBytes, language)]] -

-

[[_formatInboundBytesUnit(totalInboundBytes, language)]]

-
-

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

-
-
-
- +
@@ -727,7 +748,13 @@ export class ServerView extends DirMixin(PolymerElement) {
void = null; totalInboundBytes = 0; + totalUserHours = 0; + totalAverageDevices = 0; /** The number to which access key transfer amounts are compared for progress bar display */ baselineDataTransfer = Number.POSITIVE_INFINITY; accessKeyRows: DisplayAccessKey[] = []; @@ -922,6 +958,33 @@ export class ServerView extends DirMixin(PolymerElement) { return this.accessKeyRows.find(key => key.id === id); } + _computeServerMetrics( + totalAverageDevices: number, + totalUserHours: number, + totalInboundBytes: number, + language: string + ) { + return [ + { + icon: 'devices', + name: this.localize('server-metrics-average-devices'), + value: totalAverageDevices.toFixed(2), + }, + { + icon: 'timer', + name: this.localize('server-metrics-user-hours'), + units: this._formatHourUnits(totalUserHours, language), + value: this._formatHourValue(totalUserHours, language), + }, + { + icon: 'swap_horiz', + name: this.localize('server-metrics-data-transferred'), + units: this._formatInboundBytesUnit(totalInboundBytes, language), + value: this._formatInboundBytesValue(totalInboundBytes, language), + }, + ]; + } + _closeAddAccessKeyHelpBubble() { (this.$.addAccessKeyHelpBubble as OutlineHelpBubble).hide(); } @@ -1066,6 +1129,35 @@ export class ServerView extends DirMixin(PolymerElement) { return formatting.formatBytesParts(totalBytes, language).value; } + _formatHourUnits(hours: number, language: string) { + // This happens during app startup before we set the language + if (!language) { + return ''; + } + + const formattedValue = this._formatHourValue(hours, language); + const formattedValueAndUnit = new Intl.NumberFormat(language, { + style: 'unit', + unit: 'hour', + unitDisplay: 'long', + }).format(hours); + + return formattedValueAndUnit + .split(formattedValue) + .find(_ => _) + .trim(); + } + + _formatHourValue(hours: number, language: string) { + // This happens during app startup before we set the language + if (!language) { + return ''; + } + return new Intl.NumberFormat(language, { + unit: 'hour', + }).format(hours); + } + _formatBytesTransferred(numBytes: number, language: string, emptyValue = '') { if (!numBytes) { // numBytes may not be set for manual servers, or may be 0 for