Skip to content
Open
85 changes: 85 additions & 0 deletions server_manager/www/data_formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
*
Expand All @@ -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`.
Expand Down Expand Up @@ -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
*
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -95,17 +97,29 @@ export class AccessKeyUsageMeter extends LitElement {
max=${this.dataLimitBytes}
value=${this.dataUsageBytes}
></progress>
<label
class=${classMap({
'data-limit-warning': this.dataLimitWarning,
})}
for="progress"
>
${formatBytes(this.dataUsageBytes, this.language)} /
${formatBytes(this.dataLimitBytes, this.language)}
${this.dataLimitWarning
? `(${this.localize('server-view-access-keys-usage-limit')})`
: nothing}
</label>`;
<icon-tooltip text="${this.formatDataUsageFractionBinaryTooltip()}">
<label
class=${classMap({
'data-limit-warning': this.dataLimitWarning,
})}
for="progress"
>
${formatBytes(this.dataUsageBytes, this.language)} /
${formatBytes(this.dataLimitBytes, this.language)}
${this.dataLimitWarning
? `(${this.localize('server-view-access-keys-usage-limit')})`
: nothing}
</label>
</icon-tooltip>`;
}

/**
* 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)}`;
}
}
93 changes: 90 additions & 3 deletions server_manager/www/views/server_view/icon_tooltip/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,13 +51,38 @@ 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() {
if (this.text === undefined) {
return html`<mwc-icon>${this.icon}</mwc-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`
<span
class="tooltip-trigger"
@click=${this.insertTooltip}
@blur=${this.removeTooltip}
tabindex="0"
>
<slot></slot>
</span>
`;
}

return html`
<mwc-icon-button
@click=${this.insertTooltip}
Expand All @@ -75,6 +104,9 @@ export class IconTooltip extends LitElement {
this.tooltip = document.createElement('span');
this.tooltip.innerHTML = this.text;

const rect = this.iconElement.getBoundingClientRect();
const positioning = this.calculatePosition(rect);

// Since this element is created outside the custom element's scope,
// we can't style it here and instead must inject the styles directly.
// This too will be resolved by the Popover API
Expand All @@ -85,12 +117,10 @@ export class IconTooltip extends LitElement {
color: hsl(0, 0%, 20%);
font-family: 'Inter', system-ui;
max-width: 320px;
left: ${this.iconElement.getBoundingClientRect().left}px;
top: ${this.iconElement.getBoundingClientRect().bottom}px;
${positioning}
padding: 0.3rem;
position: fixed;
white-space: pre-line;
transform: translateX(-50%);
width: max-content;
word-wrap: break-word;
z-index: 1000;
Expand All @@ -103,6 +133,63 @@ export class IconTooltip extends LitElement {
setTimeout(this.removeTooltip, 5000);
}

private calculatePosition(rect: DOMRect): string {
const spacing = 8;
const tooltipMaxWidth = 320;
let position = this.position;

// Auto-adjust if tooltip would go off-screen
if (
position === 'right' &&
rect.right + tooltipMaxWidth + spacing > 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;
Expand Down
Loading