Skip to content

Commit

Permalink
feat(manager): use server metrics row (#2354)
Browse files Browse the repository at this point in the history
* feat(manager): use server metrics row

* ok need to test and iterate

* unit cleanup

* everything is in, style cleanup

* fix old view

* adjust fonts and colors

* formatting cleanup

* address feedback

* oops forgot other ASes

* remove devices

* fix issues

* feedback

* Update app-root.ts
  • Loading branch information
daniellacosse authored Feb 5, 2025
1 parent a687927 commit fc1d8fd
Show file tree
Hide file tree
Showing 15 changed files with 214 additions and 425 deletions.
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file modified server_manager/images/Material-Icons.woff2
Binary file not shown.
2 changes: 0 additions & 2 deletions server_manager/model/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,6 @@ export interface ServerMetrics {
location: string;
asn: number;
asOrg: string;
averageDevices: number;
userHours: number;
tunnelTime?: Duration;
dataTransferred?: Data;
}
Expand Down
1 change: 1 addition & 0 deletions server_manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"electron-updater": "^4.6.5",
"express": "^4.18.2",
"google-auth-library": "^8.9.0",
"heap-js": "^2.6.0",
"intl-messageformat": "^7.8.4",
"jsonic": "^0.3.1",
"lit": "^3.2.1",
Expand Down
132 changes: 110 additions & 22 deletions server_manager/www/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ import {CustomError} from '@outline/infrastructure/custom_error';
import * as path_api from '@outline/infrastructure/path_api';
import {sleep} from '@outline/infrastructure/sleep';
import * as Sentry from '@sentry/electron/renderer';
import {Comparator, Heap} from 'heap-js';
import * as semver from 'semver';

import {DisplayDataAmount, displayDataAmountToBytes} from './data_formatting';
import {
DisplayDataAmount,
displayDataAmountToBytes,
formatBytes,
} from './data_formatting';
import {filterOptions, getShortName} from './location_formatting';
import {parseManualServerConfig} from './management_urls';
import type {AppRoot, ServerListEntry} from './ui_components/app-root';
Expand All @@ -43,8 +48,10 @@ 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 SECONDS_IN_HOUR = 60 * 60;
const MAX_ACCESS_KEY_DATA_LIMIT_BYTES = 50 * 10 ** 9; // 50GB
const CANCELLED_ERROR = new Error('Cancelled');
const CHARACTER_TABLE_FLAG_SYMBOL_OFFSET = 127397;
export const LAST_DISPLAYED_SERVER_STORAGE_KEY = 'lastDisplayedServer';

// todo (#1311): we are referencing `@sentry/electron` which won't work for
Expand Down Expand Up @@ -1035,31 +1042,74 @@ export class App {
}
}

private async refreshServerMetrics(
private async refreshServerMetricsUI(
selectedServer: server_model.Server,
serverView: ServerView
) {
try {
const serverMetrics = await selectedServer.getServerMetrics();

let totalUserHours = 0;
let totalAverageDevices = 0;
for (const {averageDevices, userHours} of serverMetrics.server) {
totalAverageDevices += averageDevices;
totalUserHours += userHours;
let bandwidthUsageTotal = 0;
const bandwidthUsageComparator: Comparator<server_model.ServerMetrics> = (
server1,
server2
) => server2.dataTransferred.bytes - server1.dataTransferred.bytes;
const bandwidthUsageHeap = new Heap(bandwidthUsageComparator);

let tunnelTimeTotal = 0;
const tunnelTimeComparator: Comparator<server_model.ServerMetrics> = (
server1,
server2
) => server2.tunnelTime.seconds - server1.tunnelTime.seconds;
const tunnelTimeHeap = new Heap(tunnelTimeComparator);

for (const server of serverMetrics.server) {
bandwidthUsageTotal += server.dataTransferred.bytes;
bandwidthUsageHeap.push(server);

tunnelTimeTotal += server.tunnelTime.seconds;
tunnelTimeHeap.push(server);
}

serverView.totalUserHours = totalUserHours;
serverView.totalAverageDevices = totalAverageDevices;
// support legacy metrics view
serverView.totalInboundBytes = bandwidthUsageTotal;

let totalInboundBytes = 0;
for (const {dataTransferred} of serverMetrics.accessKeys) {
if (!dataTransferred) continue;

totalInboundBytes += dataTransferred.bytes;
}
const NUMBER_OF_ASES_TO_SHOW = 4;
serverView.bandwidthUsageTotal = formatBytes(
bandwidthUsageTotal,
this.appRoot.language
);

serverView.totalInboundBytes = totalInboundBytes;
serverView.bandwidthUsageRegions = bandwidthUsageHeap
.top(NUMBER_OF_ASES_TO_SHOW)
.reverse()
.map(server => ({
title: server.asOrg,
subtitle: `AS${server.asn}`,
icon: this.countryCodeToEmoji(server.location),
highlight: formatBytes(
server.dataTransferred.bytes,
this.appRoot.language
),
}));

serverView.tunnelTimeTotal = this.formatHourValue(
tunnelTimeTotal / SECONDS_IN_HOUR
);
serverView.tunnelTimeTotalLabel = this.formatHourUnits(
tunnelTimeTotal / SECONDS_IN_HOUR
);
serverView.tunnelTimeRegions = tunnelTimeHeap
.top(NUMBER_OF_ASES_TO_SHOW)
.reverse()
.map(server => ({
title: server.asOrg,
subtitle: `ASN${server.asn}`,
icon: this.countryCodeToEmoji(server.location),
highlight: this.formatHourValueAndUnit(
server.tunnelTime.seconds / SECONDS_IN_HOUR
),
}));

// Update all the displayed access keys, even if usage didn't change, in case data limits did.
const keyDataTransferMap = serverMetrics.accessKeys.reduce(
Expand Down Expand Up @@ -1101,11 +1151,49 @@ export class App {
}
}

private formatHourValueAndUnit(hours: number) {
return new Intl.NumberFormat(this.appRoot.language, {
style: 'unit',
unit: 'hour',
unitDisplay: 'long',
}).format(hours);
}

private formatHourUnits(hours: number) {
const formattedValue = this.formatHourValue(hours);
const formattedValueAndUnit = this.formatHourValueAndUnit(hours);

return formattedValueAndUnit
.split(formattedValue)
.find(_ => _)
.trim();
}

private formatHourValue(hours: number) {
return new Intl.NumberFormat(this.appRoot.language, {
unit: 'hour',
}).format(hours);
}

private countryCodeToEmoji(countryCode: string) {
if (!countryCode || !/^[A-Z]{2}$/.test(countryCode)) {
return '';
}

// Convert the country code to an emoji using Unicode regional indicator symbols
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => CHARACTER_TABLE_FLAG_SYMBOL_OFFSET + char.charCodeAt(0));

return String.fromCodePoint(...codePoints);
}

private showServerMetrics(
selectedServer: server_model.Server,
serverView: ServerView
) {
this.refreshServerMetrics(selectedServer, serverView);
this.refreshServerMetricsUI(selectedServer, serverView);
// Get transfer stats once per minute for as long as server is selected.
const statsRefreshRateMs = 60 * 1000;
const intervalId = setInterval(() => {
Expand All @@ -1114,7 +1202,7 @@ export class App {
clearInterval(intervalId);
return;
}
this.refreshServerMetrics(selectedServer, serverView);
this.refreshServerMetricsUI(selectedServer, serverView);
}, statsRefreshRateMs);
}

Expand Down Expand Up @@ -1185,7 +1273,7 @@ export class App {
this.appRoot.showNotification(this.appRoot.localize('saved'));
serverView.defaultDataLimitBytes = limit?.bytes;
serverView.isDefaultDataLimitEnabled = true;
this.refreshServerMetrics(this.selectedServer, serverView);
this.refreshServerMetricsUI(this.selectedServer, serverView);
// Don't display the feature collection disclaimer anymore.
serverView.showFeatureMetricsDisclaimer = false;
window.localStorage.setItem(
Expand All @@ -1211,7 +1299,7 @@ export class App {
await this.selectedServer.removeDefaultDataLimit();
serverView.isDefaultDataLimitEnabled = false;
this.appRoot.showNotification(this.appRoot.localize('saved'));
this.refreshServerMetrics(this.selectedServer, serverView);
this.refreshServerMetricsUI(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 @@ -1259,7 +1347,7 @@ export class App {
const serverView = await this.appRoot.getServerView(server.getId());
try {
await server.setAccessKeyDataLimit(keyId, {bytes: dataLimitBytes});
this.refreshServerMetrics(server, serverView);
this.refreshServerMetricsUI(server, serverView);
this.appRoot.showNotification(this.appRoot.localize('saved'));
return true;
} catch (error) {
Expand All @@ -1280,7 +1368,7 @@ export class App {
const serverView = await this.appRoot.getServerView(server.getId());
try {
await server.removeAccessKeyDataLimit(keyId);
this.refreshServerMetrics(server, serverView);
this.refreshServerMetricsUI(server, serverView);
this.appRoot.showNotification(this.appRoot.localize('saved'));
return true;
} catch (error) {
Expand Down
7 changes: 0 additions & 7 deletions server_manager/www/shadowbox_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ 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;
Expand Down Expand Up @@ -189,16 +186,12 @@ export class ShadowboxServer implements server.Server {

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 => ({
Expand Down
2 changes: 0 additions & 2 deletions server_manager/www/testing/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,6 @@ export class FakeManualServer
location: 'US',
asn: 10000,
asOrg: 'Fake AS',
userHours: 0,
averageDevices: 0,
},
],
accessKeys: [
Expand Down
4 changes: 2 additions & 2 deletions server_manager/www/ui_components/app-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ export class AppRoot extends polymerElementWithLocalize {
static get properties(): PolymerElementProperties {
return {
// Properties language and useKeyIfMissing are used by Polymer.AppLocalizeBehavior.
language: {type: String},
language: {type: String, value: 'en'},
supportedLanguages: {type: Array},
useKeyIfMissing: {type: Boolean},
serverList: {type: Array},
Expand Down Expand Up @@ -743,7 +743,7 @@ export class AppRoot extends polymerElementWithLocalize {
}

selectedServerId = '';
language = '';
language = 'en';
supportedLanguages: LanguageDef[] = [];
useKeyIfMissing = true;
serverList: ServerListEntry[] = [];
Expand Down
Loading

0 comments on commit fc1d8fd

Please sign in to comment.