diff --git a/server_manager/www/data_formatting.ts b/server_manager/www/data_formatting.ts index b90d230c72..9e8290d642 100644 --- a/server_manager/www/data_formatting.ts +++ b/server_manager/www/data_formatting.ts @@ -21,6 +21,11 @@ const GIGABYTE = 10 ** 9; const MEGABYTE = 10 ** 6; const KILOBYTE = 10 ** 3; +const TEBIBYTE = 1024 ** 4; +const GIBIBYTE = 1024 ** 3; +const MEBIBYTE = 1024 ** 2; +const KIBIBYTE = 1024; + const inWebApp = typeof window !== 'undefined' && typeof window.document !== 'undefined'; interface FormatParams { @@ -29,6 +34,12 @@ interface FormatParams { decimalPlaces: number; } +interface BinaryFormatParams { + value: number; + unit: 'TiB' | 'GiB' | 'MiB' | 'KiB' | 'B'; + decimalPlaces: number; +} + /** * Returns the Intl.NumberFormat options based on the magnitude of bytes passed in * @@ -48,6 +59,27 @@ export function getDataFormattingParams(numBytes: number): FormatParams { return {value: numBytes, unit: 'byte', decimalPlaces: 0}; } +/** + * Returns the binary formatting parameter based on the magnitude of bytes passed in + * + * @param numBytes the bytes to format + * @returns {BinaryFormatParams} + */ +export function getBinaryDataFormattingParams( + numBytes: number +): BinaryFormatParams { + if (numBytes >= TEBIBYTE) { + return {value: numBytes / TEBIBYTE, unit: 'TiB', decimalPlaces: 2}; + } else if (numBytes >= GIBIBYTE) { + return {value: numBytes / GIBIBYTE, unit: 'GiB', decimalPlaces: 2}; + } else if (numBytes >= MEBIBYTE) { + return {value: numBytes / MEBIBYTE, unit: 'MiB', decimalPlaces: 1}; + } else if (numBytes >= KIBIBYTE) { + return {value: numBytes / KIBIBYTE, unit: 'KiB', decimalPlaces: 0}; + } + return {value: numBytes, unit: 'B', decimalPlaces: 0}; +} + function makeDataAmountFormatter(language: string, params: FormatParams) { // We need to cast through `unknown` since `tsc` mistakenly omits the 'unit' field in // `NumberFormatOptions`. @@ -102,6 +134,40 @@ export function formatBytesParts( }; } +/** + * Returns a localized amount of bytes (binary units) as separate value and unit. + * This is useful for styling the unit and the value differently. + * + * Note: Intl.NumberFormat doesn't support binary units (KiB, MiB, etc.) in its + * unit style, so we format the number and append the unit manually. + * + * @param {number} numBytes An amount of data to format. + * @param {string} language The ISO language code for the lanugage to translate to, eg 'en'. + */ +export function formatBinaryBytesParts( + numBytes: number, + language: string +): DataAmountParts { + if (!inWebApp) { + throw new Error( + "formatBinaryBytesParts only works in web app code. Node usage isn't supported." + ); + } + const params = getBinaryDataFormattingParams(numBytes); + + // Binary units aren't supported by Intl.NumberFormat's unit style, + // so we format just the number with proper locale-specific formatting + const numberFormatter = new Intl.NumberFormat(language, { + minimumFractionDigits: params.decimalPlaces, + maximumFractionDigits: params.decimalPlaces, + }); + + return { + value: numberFormatter.format(params.value), + unit: params.unit, + }; +} + /** * Returns a string representation of a number of bytes, translated into the given language * @@ -126,6 +192,25 @@ export function formatBytes(numBytes: number, language: string): string { ); } +/** + * Returns a string representation of a number of bytes using binary units (KiB, MiB, GiB, TiB), + * translated into the given language + * + * @param {Number} numBytes An amount of data to format. + * @param {string} language The ISO language code for the language to translate to, eg 'en'. + * @returns {string} The formatted data amount with binary units. + */ +export function formatBinaryBytes(numBytes: number, language: string): string { + if (!inWebApp) { + throw new Error( + "formatBytes only works in web app code. Node usage isn't supported." + ); + } + + const parts = formatBinaryBytesParts(numBytes, language); + return `${parts.value} ${parts.unit}`; +} + // 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 { diff --git a/server_manager/www/views/server_view/access_key_data_table/access_key_usage_meter/index.ts b/server_manager/www/views/server_view/access_key_data_table/access_key_usage_meter/index.ts index 5bfb0dbe22..a5d81a9a38 100644 --- a/server_manager/www/views/server_view/access_key_data_table/access_key_usage_meter/index.ts +++ b/server_manager/www/views/server_view/access_key_data_table/access_key_usage_meter/index.ts @@ -18,7 +18,9 @@ import {LitElement, html, css, nothing} from 'lit'; import {customElement, property} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; -import {formatBytes} from '../../../../data_formatting'; +import {formatBinaryBytes, formatBytes} from '../../../../data_formatting'; + +import '../../icon_tooltip'; @customElement('access-key-usage-meter') export class AccessKeyUsageMeter extends LitElement { @@ -95,17 +97,29 @@ export class AccessKeyUsageMeter extends LitElement { max=${this.dataLimitBytes} value=${this.dataUsageBytes} > - `; + + + `; + } + + /** + * Formats the usage and limit values with binary units for tooltip display. + * + * @returns Formatted string showing "usage / limit" in binary units (KiB, MiB, GiB) + */ + private formatDataUsageFractionBinaryTooltip(): string { + return `${formatBinaryBytes(this.dataUsageBytes, this.language)} / + ${formatBinaryBytes(this.dataLimitBytes, this.language)}`; } } diff --git a/server_manager/www/views/server_view/icon_tooltip/index.ts b/server_manager/www/views/server_view/icon_tooltip/index.ts index 029bb02771..f6d9b96476 100644 --- a/server_manager/www/views/server_view/icon_tooltip/index.ts +++ b/server_manager/www/views/server_view/icon_tooltip/index.ts @@ -22,10 +22,14 @@ import '@material/mwc-icon-button'; // TODO (#2384): this tooltip is implemented by javascript and not css due to api limitations in our current version of Electron. // Once electron is updated, we should switch to the Popover API for better style control. + +type TooltipPosition = 'bottom' | 'right' | 'left' | 'top'; + @customElement('icon-tooltip') export class IconTooltip extends LitElement { @property({type: String}) text?: string; @property({type: String}) icon: string = 'help'; + @property({type: String}) position: TooltipPosition = 'bottom'; @state() tooltip?: HTMLElement; @query('mwc-icon-button') iconElement: HTMLElement; @@ -47,6 +51,14 @@ export class IconTooltip extends LitElement { mwc-icon-button { --mdc-icon-button-size: var(--icon-tooltip-button-size); } + + .tooltip-trigger { + cursor: help; + text-decoration: underline dotted; + text-decoration-color: currentColor; + text-underline-offset: 2px; + display: inline; + } `; render() { @@ -54,6 +66,23 @@ export class IconTooltip extends LitElement { return html`${this.icon}`; } + // Check if slot has content + const hasSlotContent = this.innerHTML.trim().length > 0; + + // If slot content exists, wrap that instead of showing icon button + if (hasSlotContent) { + return html` + + + + `; + } + return html` window.innerWidth + ) { + position = 'left'; + } + + if (position === 'left' && rect.left - tooltipMaxWidth - spacing < 0) { + position = 'right'; + } + + if (position === 'bottom' && rect.bottom + 100 > window.innerHeight) { + position = 'top'; + } + + if (position === 'top' && rect.top - 100 < 0) { + position = 'bottom'; + } + + switch (position) { + case 'right': + return ` + left: ${rect.right + spacing}px; + top: ${rect.top + rect.height / 2}px; + transform: translateY(-50%); + `; + + case 'left': + return ` + right: ${window.innerWidth - rect.left + spacing}px; + top: ${rect.top + rect.height / 2}px; + transform: translateY(-50%); + `; + + case 'top': + return ` + left: ${rect.left + rect.width / 2}px; + bottom: ${window.innerHeight - rect.top + spacing}px; + transform: translateX(-50%); + `; + + case 'bottom': + default: + return ` + left: ${rect.left + rect.width / 2}px; + top: ${rect.bottom + spacing}px; + transform: translateX(-50%); + `; + } + } + removeTooltip() { this.tooltip?.remove(); this.tooltip = undefined; diff --git a/server_manager/www/views/server_view/server_metrics_row/bandwidth.ts b/server_manager/www/views/server_view/server_metrics_row/bandwidth.ts index 3e81c0f3c6..793e4071b2 100644 --- a/server_manager/www/views/server_view/server_metrics_row/bandwidth.ts +++ b/server_manager/www/views/server_view/server_metrics_row/bandwidth.ts @@ -21,7 +21,11 @@ import {ifDefined} from 'lit/directives/if-defined.js'; import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import type {ServerMetricsData} from './index'; -import {formatBytes, getDataFormattingParams} from '../../../data_formatting'; +import { + formatBinaryBytes, + formatBytes, + getDataFormattingParams, +} from '../../../data_formatting'; import '../icon_tooltip'; import './index'; @@ -293,6 +297,7 @@ export class ServerMetricsBandwidthRow extends LitElement { return { title: 'Unknown', highlight: formatBytes(asn.bytes, this.language), + highlightTooltip: formatBinaryBytes(asn.bytes, this.language), }; } @@ -300,6 +305,7 @@ export class ServerMetricsBandwidthRow extends LitElement { title: asn.asOrg, subtitle: asn.asn, highlight: formatBytes(asn.bytes, this.language), + highlightTooltip: formatBinaryBytes(asn.bytes, this.language), icon: asn.countryFlag, }; })} @@ -347,20 +353,27 @@ export class ServerMetricsBandwidthRow extends LitElement { .dir=${document.documentElement.dir} >
- ${this.metrics.bandwidth - ? html` - ${this.formatBandwidthValue( - this.metrics.bandwidth.current.data.bytes - )} - ${this.formatBandwidthUnit( + + ${this.metrics.bandwidth + ? html` - ` - : html`-`} + + ${this.formatBandwidthValue( + this.metrics.bandwidth.current.data.bytes + )} + + + ${this.formatBandwidthUnit( + this.metrics.bandwidth.current.data.bytes + )} + + ` + : html`-`} + ${this.localize( 'server-view-server-metrics-bandwidth-usage' @@ -370,16 +383,22 @@ export class ServerMetricsBandwidthRow extends LitElement {
${this.metrics.bandwidth - ? html`${this.formatBandwidthValue( + ? html` + + ${this.formatBandwidthValue( this.metrics.bandwidth.peak.data.bytes - )} - ${this.formatBandwidthUnit( + )} + + + ${this.formatBandwidthUnit( this.metrics.bandwidth.peak.data.bytes - )}` + )} + + ` : html`-`} ${this.metrics.bandwidth?.peak.timestamp ? html` - ${formatBytes(this.metrics.dataTransferred.bytes, this.language)} - `; + return html` + + ${formatBytes(this.metrics.dataTransferred.bytes, this.language)} + + `; } return html` ${this.formatPercentage(this.bandwidthPercentage)} - ${formatBytes(this.metrics.dataTransferred.bytes, this.language)} - /${formatBytes(this.dataLimitBytes, this.language)} + + ${formatBytes(this.metrics.dataTransferred.bytes, this.language)} / + ${formatBytes(this.dataLimitBytes, this.language)} + + ${this.subcards.map( - ({highlight, title, subtitle, icon}) => html` + ({ + highlight, + highlightTooltip, + title, + subtitle, + icon, + }) => html` ${this.highlight - ? html`${this.highlight}` + ? this.highlightTooltip + ? html` + ${this.highlight} + ` + : html`${this.highlight}` : nothing} ${this.title ? html`

${this.title}

` : nothing} ${this.subtitle