Skip to content
Merged
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
167 changes: 140 additions & 27 deletions frontend/packages/shared/src/utils/quantities/quantity.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
import { QuantityParseError } from './errors';
import { Scale as ScaleConst, type Format, type Scale } from './types';
import {
Scale as ScaleConst,
type BinaryScale,
type DecimalFormat,
type Format,
type Scale
} from './types';

/**
* Options for formatting a quantity string for display purposes.
*
* This is separate from `toString()` which strictly follows Kubernetes canonicalization.
* Use `formatForDisplay()` for user-facing output where readability and precision control are needed.
*/
export type QuantityDisplayOptions = {
/**
* Suffix category to use when formatting.
*
* - When omitted, uses the remembered `Quantity.format` from parsing.
* - For BinarySI, scale values are interpreted as base-1024 exponents (Ki=10, Mi=20, Gi=30, etc.).
* - For DecimalSI/DecimalExponent, scale values are base-10 exponents.
*/
format?: Format;
/**
* Output scaling strategy.
*
* - `"auto"`: automatically select the most readable scale (e.g., 256.1Mi or 1.1Gi).
* - `"canonical"`: use Kubernetes canonicalization (same as `toString()`).
* - `"preserve"`: preserve parsed suffix category (best-effort, numeric part still canonicalized).
* - `Scale`: force a specific scale. For BinarySI, this maps to base-1024 exponents.
*/
scale?: Scale | 'auto' | 'canonical' | 'preserve';
type QuantityDisplayMode = 'auto' | 'canonical' | 'preserve';

type QuantityDisplayCommonOptions = {
/**
* When forcing a specific `scale`, whether to round to an integer mantissa
* using Kubernetes semantics (ceil away from 0). Defaults to `false`.
Expand All @@ -40,6 +25,73 @@ export type QuantityDisplayOptions = {
digits?: number;
};

type QuantityDisplayCanonicalOptions = QuantityDisplayCommonOptions & {
format?: Format;
scale: 'canonical';
};

type QuantityDisplayAutoOptions = QuantityDisplayCommonOptions & {
/**
* Suffix category to use when formatting.
*
* - When omitted and `scale` is also omitted, uses the remembered `Quantity.format` from parsing.
* - When omitted but `scale` is provided, the scale type selects the format family:
* `Scale` => decimal, `BinaryScale` => BinarySI.
*/
format?: Format;
/**
* Output scaling strategy.
*
* - `"auto"`: automatically select the most readable scale (e.g., 256.1Mi or 1.1Gi).
* - `"preserve"`: preserve parsed suffix category (best-effort, numeric part still canonicalized).
*/
scale?: Exclude<QuantityDisplayMode, 'canonical'>;
};

type QuantityDisplayDecimalOptions = QuantityDisplayCommonOptions & {
format: DecimalFormat;
scale?: Scale | QuantityDisplayMode;
};

type QuantityDisplayBinaryOptions = QuantityDisplayCommonOptions & {
format: 'BinarySI';
scale?: BinaryScale | QuantityDisplayMode;
};

type QuantityDisplayInferredDecimalOptions = QuantityDisplayCommonOptions & {
format?: undefined;
scale: Scale;
};

type QuantityDisplayInferredBinaryOptions = QuantityDisplayCommonOptions & {
format?: undefined;
scale: BinaryScale;
};

type QuantityDisplayTypedOptions =
| QuantityDisplayCanonicalOptions
| QuantityDisplayDecimalOptions
| QuantityDisplayBinaryOptions
| QuantityDisplayInferredDecimalOptions
| QuantityDisplayInferredBinaryOptions;

/**
* Options for formatting a quantity string for display purposes.
*
* This is separate from `toString()` which strictly follows Kubernetes canonicalization.
* Use `formatForDisplay()` for user-facing output where readability and precision control are needed.
*
* When `format` is provided, `scale` must use the matching family:
* - `BinarySI` => `BinaryScale`
* - `DecimalSI` / `DecimalExponent` => `Scale`
*
* When `format` is omitted and a numeric scale is provided, the scale type selects the format family.
* At runtime, branded scales are plain numbers, so BinarySI auto-inference recognizes the exported
* `BinaryScale` exponents (`10 | 20 | 30 | 40 | 50 | 60`). Use an explicit `format` for overlapping
* custom decimal exponents.
*/
export type QuantityDisplayOptions = QuantityDisplayAutoOptions | QuantityDisplayTypedOptions;

const MAX_INT64: bigint = (1n << 63n) - 1n;

const absBigInt = (v: bigint): bigint => (v < 0n ? -v : v);
Expand Down Expand Up @@ -486,6 +538,25 @@ const parseFromParts = (parts: ParsedParts): { value: bigint; scale: number; for
const DECIMAL_SI_EXPONENTS: readonly number[] = [-9, -6, -3, 0, 3, 6, 9, 12, 15, 18] as const;
const BINARY_SI_BITS: readonly number[] = [0, 10, 20, 30, 40, 50, 60] as const;

const isBinaryScaleValue = (scale: unknown): scale is BinaryScale => {
if (typeof scale !== 'number') return false;
switch (scale) {
case 10:
case 20:
case 30:
case 40:
case 50:
case 60:
return true;
default:
return false;
}
};

const resolveDecimalDisplayFormat = (fallbackFormat: Format): DecimalFormat => {
return fallbackFormat === 'DecimalExponent' ? 'DecimalExponent' : 'DecimalSI';
};

const clampToDecimalSIExponent = (exp: number): number => {
// Snap to multiples of 3 and clamp into [-9, 18]
let e = Math.trunc(exp / 3) * 3;
Expand Down Expand Up @@ -550,6 +621,15 @@ const buildMantissaRationalForBinary = (
return { numer: value, denom: two * pow10BigInt(-valueScale) };
};

const binaryScaledInt = (
value: bigint,
valueScale: number,
targetBits: number
): { q: bigint; exact: boolean } => {
const r = buildMantissaRationalForBinary(value, valueScale, targetBits);
return divRoundAwayFromZero(r.numer, r.denom);
};

const roundedAbsScaled = (numer: bigint, denom: bigint, digits: number): bigint => {
// returns |numer/denom| rounded to `digits` decimal places, as an integer scaled by 10^digits
const a = absBigInt(numer);
Expand Down Expand Up @@ -783,9 +863,21 @@ export class Quantity {
* - `round`: `false`
* - `digits`: `2`
*
* If `format` is provided, `scale` must use the matching family:
* - `BinarySI` => `BinaryScale`
* - `DecimalSI` / `DecimalExponent` => `Scale`
*
* If `format` is omitted and a numeric scale is provided, the scale type selects the
* format family (`Scale` => decimal, `BinaryScale` => BinarySI).
* Because branded scales are numbers at runtime, omitting `format` with a custom decimal exponent
* equal to a BinarySI exponent (10/20/30/40/50/60) is ambiguous; pass `format` explicitly there.
*
* @param options - Display formatting options; all have defaults and can be omitted
* @returns Formatted string suitable for UI display
*/
formatForDisplay(): string;
formatForDisplay(options: QuantityDisplayTypedOptions): string;
formatForDisplay(options: QuantityDisplayAutoOptions): string;
formatForDisplay(options?: QuantityDisplayOptions): string {
const opts = options ?? {};
const scaleOpt = opts.scale ?? 'auto';
Expand All @@ -798,7 +890,13 @@ export class Quantity {

if (this.#value === 0n) return '0';

const format: Format = opts.format ?? this.format;
const format: Format =
opts.format ??
(typeof scaleOpt === 'string'
? this.format
: isBinaryScaleValue(scaleOpt)
? 'BinarySI'
: resolveDecimalDisplayFormat(this.format));
const roundInt = opts.round === true;

const digits: number = roundInt ? 0 : opts.digits ?? 2;
Expand All @@ -818,8 +916,10 @@ export class Quantity {
if (typeof scaleOpt === 'string') {
// 'auto' | 'preserve'
bits = chooseAutoBinaryBits(absValue, this.#scale);
} else {
} else if (isBinaryScaleValue(scaleOpt)) {
bits = Number(scaleOpt);
} else {
throw new TypeError('BinarySI display requires BinaryScale when forcing a scale');
}
bits = clampToBinarySIBits(bits);

Expand Down Expand Up @@ -849,6 +949,8 @@ export class Quantity {
if (typeof scaleOpt === 'string') {
// 'auto' | 'preserve'
exp10 = chooseAutoDecimalExponent(absValue, this.#scale);
} else if (isBinaryScaleValue(scaleOpt)) {
throw new TypeError(`${format} display requires decimal Scale when forcing a scale`);
} else {
exp10 = Number(scaleOpt);
}
Expand Down Expand Up @@ -945,6 +1047,17 @@ export class Quantity {
return this.scaledValue(ScaleConst.Milli);
}

/**
* Return the value of `ceilAwayFromZero(q / 2^scale)` using BinarySI semantics,
* but returned as `bigint` to avoid JS overflow.
*
* This mirrors `scaledValue()` for explicit base-1024 unit conversion.
*/
scaledBinaryValue(scale: BinaryScale): bigint {
const { q } = binaryScaledInt(this.#value, this.#scale, Number(scale));
return q;
}

/**
* Return the value of `ceilAwayFromZero(q / 10^scale)` using int64-like semantics,
* but returned as `bigint` to avoid JS overflow.
Expand Down
19 changes: 19 additions & 0 deletions frontend/packages/shared/src/utils/quantities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@
*/
export type Format = 'DecimalExponent' | 'BinarySI' | 'DecimalSI';

export type DecimalFormat = Exclude<Format, 'BinarySI'>;

export type BinaryScaleExponent = 10 | 20 | 30 | 40 | 50 | 60;

declare const binaryScaleBrand: unique symbol;

/** Base-1024 scale used by BinarySI quantities (Kibi=2^10, Mebi=2^20, Gibi=2^30, ...). */
export type BinaryScale = number & { readonly [binaryScaleBrand]: unique symbol };

/** Common base-1024 scales used by BinarySI quantities. */
export const BinaryScale = {
Kibi: 10 as BinaryScale,
Mebi: 20 as BinaryScale,
Gibi: 30 as BinaryScale,
Tebi: 40 as BinaryScale,
Pebi: 50 as BinaryScale,
Exbi: 60 as BinaryScale
} as const;

/** From Kubernetes: 10^scale */
export type Scale = number & { readonly __brand: unique symbol };

Expand Down
22 changes: 10 additions & 12 deletions frontend/providers/dbprovider/src/utils/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dayjs.extend(timezone);
import yaml from 'js-yaml';
import ini from 'ini';
import { DBType, PodDetailType } from '@/types/db';
import { Quantity, Scale } from '@sealos/shared';
import { BinaryScale, Quantity, Scale } from '@sealos/shared';

export const formatTime = (time: string | number | Date, format = 'YYYY-MM-DD HH:mm:ss') => {
return dayjs(time).tz('Asia/Shanghai').format(format);
Expand Down Expand Up @@ -131,8 +131,7 @@ export const cpuFormatToC = (cpu: string | number = '0'): string => {
*/
export const memoryFormatToGi = (memory: string | number = '0'): string => {
return Quantity.fromJSON(memory).formatForDisplay({
scale: Scale.Giga,
format: 'BinarySI',
scale: BinaryScale.Gibi,
round: false,
digits: 3
});
Expand All @@ -145,10 +144,7 @@ export const memoryFormatToGi = (memory: string | number = '0'): string => {
*/
export const storageFormatToNum = (storage = '0') => {
return (
Math.round(
(Number(Quantity.fromJSON(storage).withFormat('BinarySI').scaledValue(Scale.Mega)) / 1024) *
100
) / 100
Math.round(Number(Quantity.fromJSON(storage).scaledBinaryValue(BinaryScale.Gibi)) * 100) / 100
);
};

Expand All @@ -161,11 +157,13 @@ export const storageFormatToNum = (storage = '0') => {
* @deprecated We need to migrate all resource representations to Quantity (in the near future).
*/
export const storageFormatToGi = (value: string | undefined, defaultValue: number = 0): number => {
return (
Math.round(
(Number(Quantity.fromJSON(value).withFormat('BinarySI').scaledValue(Scale.Mega)) / 1024) * 100
) / 100
);
try {
return (
Math.round(Number(Quantity.fromJSON(value).scaledBinaryValue(BinaryScale.Gibi)) * 100) / 100
);
} catch {
return defaultValue;
}
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { GetObjectCommand, S3 } from '@aws-sdk/client-s3';
import { useToast } from '@/hooks/useToast';
import { useCallback, useDeferredValue, useEffect, useMemo, useState, useTransition } from 'react';
import ArrowDownSLineIcon from '../Icons/ArrowDownSLineIcon';
import { formatBytes, useCopyData } from '@/utils/tools';
import { formatBytesForDisplay, useCopyData } from '@/utils/tools';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { format } from 'date-fns';
import DeleteFileModal from '../common/modal/DeleteFileModal';
Expand Down Expand Up @@ -361,7 +361,7 @@ export default function FileManager({ ...styles }: FlexProps) {
const file = props.row.original;
return (
<Text color={'grayModern.600'}>
{file.isDir ? '--' : formatBytes(file.Size || 0).toString()}
{file.isDir ? '--' : formatBytesForDisplay(file.Size || 0)}
</Text>
);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { monitor } from '@/api/monitor';
import { useOssStore } from '@/store/ossStore';
import { formatBytesForDisplay, formatCountForDisplay } from '@/utils/tools';
import { Box, BoxProps, Flex } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { connect } from 'echarts';
Expand All @@ -12,6 +13,11 @@ const titles = [
'minio_bucket_traffic_received_bytes',
'minio_bucket_traffic_sent_bytes'
] as const;
const byteMetricTitles = new Set([
'minio_bucket_usage_total_bytes',
'minio_bucket_traffic_received_bytes',
'minio_bucket_traffic_sent_bytes'
]);
const chartStyles = [
{
lineColor: 'rgba(54, 173, 239, 1)',
Expand Down Expand Up @@ -58,17 +64,19 @@ export default function DataMonitor({ ...styles }: BoxProps) {
if (monitorQuery.isSuccess && monitorQuery.data) {
const _data = monitorQuery.data;
for (let i = 0; i < 4; i++) {
const curdata = _data?.[i]?.reduce<[number, string][]>((pre, cur) => {
const curdata = _data?.[i]?.reduce<[number, number][]>((pre, cur) => {
// single pool
const column = cur.values.map<[number, string]>(([time, v]) => [time * 1000, v]);
const column = cur.values.map<[number, number]>(([time, v]) => [time * 1000, Number(v)]);
return [...pre, ...column];
}, []);
curdata.sort((a, b) => a[0] - b[0]);
data[i].push(...curdata);
curdata?.sort((a, b) => a[0] - b[0]);
if (curdata) {
data[i].push(...curdata);
}
}
}
return data;
}, [monitorQuery.data]);
}, [monitorQuery.data, monitorQuery.isSuccess]);
return (
<Box mt="32px" flex={'1 1 0'} h="0" overflowY={'auto'} {...styles}>
<Flex
Expand All @@ -84,6 +92,11 @@ export default function DataMonitor({ ...styles }: BoxProps) {
title={t(title)}
dimesion={title}
source={data[idx]}
valueFormatter={
byteMetricTitles.has(title)
? (value) => formatBytesForDisplay(value)
: (value) => formatCountForDisplay(value)
}
styles={chartStyles[idx]}
onChartReady={(instance) => {
instance.group = 'group1';
Expand Down
Loading
Loading