Skip to content

Commit

Permalink
feat(manager): move and update metrics cards in the server view (#2241)
Browse files Browse the repository at this point in the history
* feat(manager): move and update metrics cards in the server view

* getting close. trying to figure out this weird data model

* style update

* don't use reduce lol

* okay  it roughly works end to end

* add metrics messages

* access key tab

* update to use new "endpoint" - feedback next

* feedback

* endpoint implemented

* Update server_manager/www/app.ts

Co-authored-by: Vinicius Fortuna <[email protected]>

* Update server_manager/model/server.ts

Co-authored-by: Vinicius Fortuna <[email protected]>

* partial feedback

* feedback - needs test

* move implementatino

* fix

* fix unit formatting

* refactor for testing

* Update server_manager/www/shadowbox_server.ts

Co-authored-by: Sander Bruens <[email protected]>

* partial feedback

* simply method and remove from model

* make private

---------

Co-authored-by: Vinicius Fortuna <[email protected]>
Co-authored-by: Sander Bruens <[email protected]>
  • Loading branch information
3 people authored Jan 29, 2025
1 parent c1f0be6 commit aee140a
Show file tree
Hide file tree
Showing 9 changed files with 438 additions and 84 deletions.
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": {
"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": {
"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
43 changes: 31 additions & 12 deletions server_manager/model/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ 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<{
server: ServerMetrics[];
accessKeys: AccessKeyMetrics[];
}>;

// Adds a new access key to this server.
addAccessKey(): Promise<AccessKey>;
Expand All @@ -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<void>;
setDefaultDataLimit(limit: Data): Promise<void>;

// 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
Expand All @@ -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<void>;
setAccessKeyDataLimit(accessKeyId: AccessKeyId, limit: Data): Promise<void>;

// 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.
Expand Down Expand Up @@ -149,6 +149,7 @@ export interface ManagedServerHost {
delete(): Promise<void>;
}

// TODO: refactor to the `Data` type, see below
export class DataAmount {
terabytes: number;
}
Expand Down Expand Up @@ -183,13 +184,31 @@ export interface AccessKey {
id: AccessKeyId;
name: string;
accessUrl: string;
dataLimit?: DataLimit;
dataLimit?: Data;
}

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 {
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;
}
31 changes: 31 additions & 0 deletions server_manager/www/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
71 changes: 49 additions & 22 deletions server_manager/www/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Sentry.init({

function displayDataAmountToDataLimit(
dataAmount: DisplayDataAmount
): server_model.DataLimit | null {
): server_model.Data | null {
if (!dataAmount) {
return null;
}
Expand All @@ -75,7 +75,7 @@ function displayDataAmountToDataLimit(
async function computeDefaultDataLimit(
server: server_model.Server,
accessKeys?: server_model.AccessKey[]
): Promise<server_model.DataLimit> {
): Promise<server_model.Data> {
try {
// Assume non-managed servers have a data transfer capacity of 1TB.
let serverTransferCapacity: server_model.DataAmount = {terabytes: 1};
Expand Down Expand Up @@ -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);
}

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

Expand Down Expand Up @@ -1142,7 +1169,7 @@ export class App {
});
}

private async setDefaultDataLimit(limit: server_model.DataLimit) {
private async setDefaultDataLimit(limit: server_model.Data) {
if (!limit) {
return;
}
Expand All @@ -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(
Expand All @@ -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'));
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit aee140a

Please sign in to comment.