Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(manager): move and update metrics cards in the server view #2241

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added server_manager/images/Material-Icons.woff2
Binary file not shown.
14 changes: 14 additions & 0 deletions server_manager/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
/>

<style>
/*
TODO: currently webpack wants to relove the `url` link here but doesen't know how.
Index.html isn't transformed by webpack, so this sidesteps the issue, but ideally we teach webpack how to handle this filetype.
*/
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(images/Material-Icons.woff2) format('woff2');
}
</style>

<title>Outline Manager</title>
</head>
<body>
Expand Down
26 changes: 26 additions & 0 deletions server_manager/messages/master_messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
"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."
Expand Down Expand Up @@ -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": {
daniellacosse marked this conversation as resolved.
Show resolved Hide resolved
"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."
Expand Down
29 changes: 25 additions & 4 deletions server_manager/model/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export interface Server {
// Lists the access keys for this server, including the admin.
listAccessKeys(): Promise<AccessKey[]>;

// Returns stats for bytes transferred across all access keys of this server.
getDataUsage(): Promise<BytesByAccessKey>;
// Returns server metrics
getServerMetrics(): Promise<ServerMetricsJson>;

// Adds a new access key to this server.
addAccessKey(): Promise<AccessKey>;
Expand Down Expand Up @@ -186,10 +186,31 @@ export interface AccessKey {
dataLimit?: DataLimit;
}

export type BytesByAccessKey = Map<AccessKeyId, number>;

// Data transfer allowance, measured in bytes.
// NOTE: Must be kept in sync with the definition in src/shadowbox/access_key.ts.
export interface DataLimit {
readonly bytes: number;
}

export type ServerMetricsJson = {
server: {
location: string;
asn: number;
asOrg: string;
tunnelTime?: {
seconds: number;
};
dataTransferred?: {
bytes: number;
};
}[];
accessKeys: {
accessKeyId: number;
tunnelTime?: {
seconds: number;
};
dataTransferred?: {
bytes: number;
};
}[];
};
61 changes: 42 additions & 19 deletions server_manager/www/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ 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 HOUR_IN_SECS = 60 * 60;
const MONTH_IN_HOURS = 30 * 24;
const CANCELLED_ERROR = new Error('Cancelled');
export const LAST_DISPLAYED_SERVER_STORAGE_KEY = 'lastDisplayedServer';

Expand Down Expand Up @@ -970,7 +972,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);
}

Expand Down Expand Up @@ -1035,30 +1037,51 @@ 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;
for (const {tunnelTime} of serverMetrics.server) {
totalUserHours += tunnelTime.seconds / HOUR_IN_SECS;
}

serverView.totalUserHours = totalUserHours;
serverView.totalDevices = serverView.totalUserHours / MONTH_IN_HOURS;

let totalInboundBytes = 0;
for (const accessKeyBytes of keyTransfers) {
totalInboundBytes += accessKeyBytes;
for (const {dataTransferred} of serverMetrics.accessKeys) {
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}) => {
map.set(String(accessKeyId), dataTransferred.bytes);
return map;
},
new Map<string, number>()
);

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
Expand All @@ -1074,11 +1097,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(() => {
Expand All @@ -1087,7 +1110,7 @@ export class App {
clearInterval(intervalId);
return;
}
this.refreshTransferStats(selectedServer, serverView);
this.refreshServerMetrics(selectedServer, serverView);
}, statsRefreshRateMs);
}

Expand Down Expand Up @@ -1158,7 +1181,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(
Expand All @@ -1184,7 +1207,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'));
Expand Down Expand Up @@ -1232,7 +1255,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) {
Expand All @@ -1253,7 +1276,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) {
Expand Down
48 changes: 43 additions & 5 deletions server_manager/www/shadowbox_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,17 @@ function makeAccessKeyModel(apiAccessKey: AccessKeyJson): server.AccessKey {
export class ShadowboxServer implements server.Server {
private api: PathApiClient;
private serverConfig: ServerConfigJson;
private supportedEndpoints: {
'experimental/server/metrics': boolean;
};

constructor(private readonly id: string) {}
constructor(private readonly id: string) {
this.id = id;

this.supportedEndpoints = {
'experimental/server/metrics': false,
};
}

getId(): string {
return this.id;
Expand Down Expand Up @@ -149,16 +158,31 @@ export class ShadowboxServer implements server.Server {
await this.api.request<void>(`access-keys/${keyId}/data-limit`, 'DELETE');
}

async getDataUsage(): Promise<server.BytesByAccessKey> {
async getServerMetrics(): Promise<server.ServerMetricsJson> {
if (this.supportedEndpoints['experimental/server/metrics']) {
return this.api.request<server.ServerMetricsJson>(
'experimental/server/metrics?since=30d'
);
}

const result: server.ServerMetricsJson = {
server: [],
accessKeys: [],
};

const jsonResponse =
await this.api.request<DataUsageByAccessKeyJson>('metrics/transfer');
const usageMap = new Map<server.AccessKeyId, number>();

for (const [accessKeyId, bytes] of Object.entries(
jsonResponse.bytesTransferredByUserId
)) {
usageMap.set(accessKeyId, bytes ?? 0);
result.accessKeys.push({
accessKeyId: Number(accessKeyId),
dataTransferred: {bytes},
});
}
return usageMap;

return result;
}

getName(): string {
Expand Down Expand Up @@ -262,6 +286,20 @@ export class ShadowboxServer implements server.Server {

protected setManagementApi(api: PathApiClient): void {
this.api = api;

this.api
.request<server.ServerMetricsJson>(
'experimental/server/metrics?since=30d'
)
.then(
() => (this.supportedEndpoints['experimental/server/metrics'] = true)
)
.catch(error => {
// endpoint is not defined, keep set to false
if (error.response.status === 404) return;

this.supportedEndpoints['experimental/server/metrics'] = true;
});
}

getManagementApiUrl(): string {
Expand Down
33 changes: 33 additions & 0 deletions server_manager/www/testing/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ export class FakeServer implements server.Server {
getDataUsage() {
return Promise.resolve(new Map<server.AccessKeyId, number>());
}
getServerMetrics(): Promise<server.ServerMetricsJson> {
return Promise.reject(
new Error('FakeServer.getServerMetrics not implemented')
);
}
addAccessKey() {
const accessKey = {
id: Math.floor(Math.random()).toString(),
Expand Down Expand Up @@ -246,6 +251,34 @@ export class FakeManualServer
getCertificateFingerprint() {
return this.manualServerConfig.certSha256;
}
getServerMetrics() {
return Promise.resolve({
server: [
{
location: '',
asn: 0,
asOrg: '',
tunnelTime: {
seconds: 0,
},
dataTransferred: {
bytes: 0,
},
},
],
accessKeys: [
{
accessKeyId: 0,
tunnelTime: {
seconds: 0,
},
dataTransferred: {
bytes: 0,
},
},
],
});
}
}

export class FakeManualServerRepository
Expand Down
Loading
Loading