diff --git a/doc/guide/cockpit-util.xml b/doc/guide/cockpit-util.xml index 9bff53e62e4..c2db7ea6719 100644 --- a/doc/guide/cockpit-util.xml +++ b/doc/guide/cockpit-util.xml @@ -44,75 +44,44 @@ string = cockpit.format_number(number, [precision]) cockpit.format_bytes() -string = cockpit.format_bytes(number, [factor]) -array = cockpit.format_bytes(number, [factor, options]) +string = cockpit.format_bytes(number, [options]) Formats number into a displayable string with a suffix, such as - KB or MB. Returns an array of the - formatted number and the suffix if options.separate is set to true. + KB or MB. - If specifying 1000 or 1024 is specified as a factor then an appropriate suffix - will be chosen. By default the factor is 1000. You can pass a string suffix as a - factor in which case the resulting number will be formatted with the same suffix. - - If the number is less than the factor or an unknown factor - was passed in, then the formatted number is returned without a suffix. If options.separate - is true, returns an array of [formatted_number, suffix] or - [formatted_number] if returned without a suffix. + By default, SI units are used. IEC units (1024-based) can be requested by including + base2: true in options. By default, non-integer numbers will be formatted with 3 digits of precision. This can be changed with options.precision. - If number is null or undefined an empty string or - an array without a suffix will be returned. + If number is null or undefined an empty string will be + returned. cockpit.format_bytes_per_sec() - string = cockpit.format_bytes_per_sec(number, [factor]) - array = cockpit.format_bytes_per_sec(number, [factor, options]) +string = cockpit.format_bytes_per_sec(number, [options]) Format number of bytes into a displayable speed string. - If specifying 1000 or 1024 is specified as a factor then an appropriate suffix - will be chosen. By default the factor is 1000. You can pass a string suffix as a - factor in which case the resulting number will be formatted with the same suffix. - - If the number is less than the factor or an unknown factor - was passed in, then the formatted number is returned without a suffix. If options.separate - is true, returns an array of [formatted_number, suffix] or - [formatted_number] if returned without a suffix. - - By default, non-integer numbers will be formatted with 3 digits of precision. This can be changed - with options.precision. - - If number is null or undefined an empty string or array - will be returned. + This function is mostly equivalent to cockpit.format_bytes() but the returned + value contains a unit like KB/s or MB/s. cockpit.format_bits_per_sec() - string = cockpit.format_bits_per_sec(number, [factor]) - array = cockpit.format_bytes_per_sec(number, [factor, options]) +string = cockpit.format_bits_per_sec(number, [options]) Format number of bits into a displayable speed string. - If specifying 1000 or 1024 is specified as a factor then an appropriate suffix - will be chosen. By default the factor is 1000. You can pass a string suffix as a - factor in which case the resulting number will be formatted with the same suffix. - - If the number is less than the factor or an unknown factor - was passed in, then the formatted number is returned without a suffix. If options.separate - is true, returns an array of [formatted_number, suffix] or - [formatted_number] if returned without a suffix. - - By default, non-integer numbers will be formatted with 3 digits of precision. This can be changed - with options.precision. + This function is mostly equivalent to cockpit.format_bytes() but the returned + value contains a unit like kbps or Mbps. - If number is null or undefined an empty string or array - will be returned. + This function does not support IEC units. base2 may not be passed as part of + options. diff --git a/files.js b/files.js index 32fe3e834c3..45254199952 100644 --- a/files.js +++ b/files.js @@ -53,7 +53,7 @@ const info = { "base1/test-events.js", "base1/test-external.js", "base1/test-file.js", - "base1/test-format.js", + "base1/test-format.ts", "base1/test-framed-cache.js", "base1/test-framed.js", "base1/test-http.js", diff --git a/pkg/base1/test-format.js b/pkg/base1/test-format.ts similarity index 74% rename from pkg/base1/test-format.js rename to pkg/base1/test-format.ts index 0294fcd6c80..ce60c18a116 100644 --- a/pkg/base1/test-format.js +++ b/pkg/base1/test-format.ts @@ -1,5 +1,5 @@ import cockpit from "cockpit"; -import QUnit from "qunit-tests"; +import QUnit, { f } from "qunit-tests"; QUnit.test("format", function (assert) { assert.equal(cockpit.format("My $adj message with ${amount} of things", { adj: "special", amount: "lots" }), @@ -42,7 +42,7 @@ QUnit.test("format_number", function (assert) { [-123.01, "-123", "-123"], [null, "", ""], [undefined, "", ""], - ]; + ] as const; const saved_language = cockpit.language; @@ -51,19 +51,22 @@ QUnit.test("format_number", function (assert) { cockpit.language = 'en'; for (let i = 0; i < checks.length; i++) { assert.strictEqual(cockpit.format_number(checks[i][0]), checks[i][1], - "format_number@en(" + checks[i][0] + ") = " + checks[i][1]); + f`format_number@en(${checks[i][0]})` + ); } cockpit.language = 'de'; for (let i = 0; i < checks.length; i++) { assert.strictEqual(cockpit.format_number(checks[i][0]), checks[i][2], - "format_number@de(" + checks[i][0] + ") = " + checks[i][2]); + f`format_number@de(${checks[i][0]})` + ); } cockpit.language = 'pt_BR'; for (let i = 0; i < checks.length; i++) { assert.strictEqual(cockpit.format_number(checks[i][0]), checks[i][2], - "format_number@pt_BR(" + checks[i][0] + ") = " + checks[i][2]); + f`format_number@pt_BR(${checks[i][0]})` + ); } /* restore this as not to break the other tests */ @@ -101,17 +104,30 @@ QUnit.test("format_bytes", function (assert) { [0, "KB", "0 KB"], [undefined, "KB", ""], [null, "KB", ""], - ]; + ] as const; - assert.expect(checks.length * 2 + 2); + for (let i = 0; i < checks.length; i++) { + if (typeof checks[i][1] === 'string') { + // these tests are for backwards compatibility only + continue; + } + + const base2 = checks[i][1] == 1024; + assert.strictEqual(cockpit.format_bytes(checks[i][0], { base2 }), checks[i][2], + f`format_bytes(${checks[i][0]}, ${{ base2 }})`); + } + + // old API style (deprecated) for (let i = 0; i < checks.length; i++) { assert.strictEqual(cockpit.format_bytes(checks[i][0], checks[i][1]), checks[i][2], - "format_bytes(" + checks[i][0] + ", " + String(checks[i][1]) + ") = " + checks[i][2]); + f`format_bytes(${checks[i][0]}, ${checks[i][1]})` + ); } for (let i = 0; i < checks.length; i++) { const split = checks[i][2].split(" "); assert.deepEqual(cockpit.format_bytes(checks[i][0], checks[i][1], { separate: true }), split, - "format_bytes(" + checks[i][0] + ", " + String(checks[i][1]) + ", true) = " + split); + f`format_bytes(${checks[i][0]}, ${checks[i][1]}, ${{ separate: true }})` + ); } // backwards compatible API: format_bytes with a boolean options (used to be a single "separate" flag) @@ -119,38 +135,6 @@ QUnit.test("format_bytes", function (assert) { assert.deepEqual(cockpit.format_bytes(2500000, 1000, true), ["2.50", "MB"]); }); -QUnit.test("get_byte_units", function (assert) { - const mib = 1024 * 1024; - const gib = mib * 1024; - const tib = gib * 1024; - - const mib_unit = { factor: mib, name: "MiB" }; - const gib_unit = { factor: gib, name: "GiB" }; - const tib_unit = { factor: tib, name: "TiB" }; - - function selected(unit) { - return { factor: unit.factor, name: unit.name, selected: true }; - } - - const checks = [ - [0 * mib, 1024, [selected(mib_unit), gib_unit, tib_unit]], - [20 * mib, 1024, [selected(mib_unit), gib_unit, tib_unit]], - [200 * mib, 1024, [selected(mib_unit), gib_unit, tib_unit]], - [2000 * mib, 1024, [selected(mib_unit), gib_unit, tib_unit]], - [20000 * mib, 1024, [mib_unit, selected(gib_unit), tib_unit]], - [20 * gib, 1024, [mib_unit, selected(gib_unit), tib_unit]], - [200 * gib, 1024, [mib_unit, selected(gib_unit), tib_unit]], - [2000 * gib, 1024, [mib_unit, selected(gib_unit), tib_unit]], - [20000 * gib, 1024, [mib_unit, gib_unit, selected(tib_unit)]] - ]; - - assert.expect(checks.length); - for (let i = 0; i < checks.length; i++) { - assert.deepEqual(cockpit.get_byte_units(checks[i][0], checks[i][1]), checks[i][2], - "get_byte_units(" + checks[i][0] + ", " + checks[i][1] + ") = " + JSON.stringify(checks[i][2])); - } -}); - QUnit.test("format_bytes_per_sec", function (assert) { const checks = [ // default unit @@ -171,18 +155,28 @@ QUnit.test("format_bytes_per_sec", function (assert) { // significant integer digits exceed custom precision [25555000, "kB/s", { precision: 2 }, "25555 kB/s"], [25555678, "kB/s", { precision: 2 }, "25556 kB/s"], - ]; + ] as const; - assert.expect(checks.length + 2); for (let i = 0; i < checks.length; i++) { - assert.strictEqual(cockpit.format_bytes_per_sec(checks[i][0], checks[i][1], checks[i][2]), checks[i][3], - `format_bytes_per_sec(${checks[i][0]}, ${checks[i][1]}, ${checks[i][2]}) = ${checks[i][3]}`); + if (typeof checks[i][1] === 'string') { + // these tests are for backwards compatibility only + continue; + } + + const base2 = checks[i][1] == 1024; + assert.strictEqual(cockpit.format_bytes_per_sec(checks[i][0], { base2, ...checks[i][2] }), checks[i][3], + f`format_bytes_per_sec(${checks[i][0]}, ${{ base2, ...checks[i][2] }})`); } - // separate unit + // old API style (deprecated) + for (let i = 0; i < checks.length; i++) { + assert.strictEqual(cockpit.format_bytes_per_sec(checks[i][0], checks[i][1], checks[i][2]), checks[i][3], + f`format_bytes_per_sec(${checks[i][0]}, ${checks[i][1]}, ${checks[i][2]})`); + } + // separate unit (very deprecated) assert.deepEqual(cockpit.format_bytes_per_sec(2555, 1024, { separate: true }), ["2.50", "KiB/s"]); - // backwards compatible API for separate flag + // backwards compatible API for separate flag (oh so very deprecated) assert.deepEqual(cockpit.format_bytes_per_sec(2555, 1024, true), ["2.50", "KiB/s"]); }); @@ -195,12 +189,12 @@ QUnit.test("format_bits_per_sec", function (assert) { [2555, "2.56 Kbps"], [2000, "2 Kbps"], [2003, "2.00 Kbps"] - ]; + ] as const; assert.expect(checks.length); for (let i = 0; i < checks.length; i++) { assert.strictEqual(cockpit.format_bits_per_sec(checks[i][0]), checks[i][1], - "format_bits_per_sec(" + checks[i][0] + ") = " + checks[i][1]); + f`format_bits_per_sec(${checks[i][0]})`); } }); diff --git a/pkg/lib/cockpit.d.ts b/pkg/lib/cockpit.d.ts index 4a95c0f09a5..54100f1c9ae 100644 --- a/pkg/lib/cockpit.d.ts +++ b/pkg/lib/cockpit.d.ts @@ -28,6 +28,8 @@ declare module 'cockpit' { function assert(predicate: unknown, message?: string): asserts predicate; + export let language: string; + /* === Events mix-in ========================= */ interface EventMap { @@ -193,17 +195,6 @@ declare module 'cockpit' { /* === String helpers ======================== */ - type FormatOptions = { - precision?: number; - separate?: boolean; - }; - - type ByteUnit = { - name: string | null; - factor: number; - selected?: boolean; - }; - function message(problem: string | JsonObject): string; function gettext(message: string): string; @@ -212,12 +203,21 @@ declare module 'cockpit' { function ngettext(context: string, message1: string, messageN: string, n: number): string; function format(format_string: string, ...args: unknown[]): string; - function format_number(n: number, precision?: number): string - function format_bytes(n: number, factor?: 1000 | 1024, options?: FormatOptions & { separate?: false }): string; - function format_bytes(n: number, factor: 1000 | 1024, options: FormatOptions & { separate: true }): string[]; - function format_bytes_per_sec(n: number, factor?: 1000 | 1024, options?: FormatOptions & { separate?: false }): string; - function format_bytes_per_sec(n: number, factor: 1000 | 1024, options: FormatOptions & { separate: true }): string[]; - function format_bits_per_sec(n: number, factor?: 1000 | 1024, options?: FormatOptions & { separate?: false }): string; - function format_bits_per_sec(n: number, factor: 1000 | 1024, options: FormatOptions & { separate: true }): string[]; - function get_byte_units(guide_value: number, factor?: 1000 | 1024): ByteUnit[]; + + /* === Number formatting ===================== */ + + type FormatOptions = { + precision?: number; + base2?: boolean; + }; + type MaybeNumber = number | null | undefined; + + function format_number(n: MaybeNumber, precision?: number): string + function format_bytes(n: MaybeNumber, options?: FormatOptions): string; + function format_bytes_per_sec(n: MaybeNumber, options?: FormatOptions): string; + function format_bits_per_sec(n: MaybeNumber, options?: FormatOptions & { base2?: false }): string; + + /** @deprecated */ function format_bytes(n: MaybeNumber, factor: unknown, options?: object | boolean): string | string[]; + /** @deprecated */ function format_bytes_per_sec(n: MaybeNumber, factor: unknown, options?: object | boolean): string | string[]; + /** @deprecated */ function format_bits_per_sec(n: MaybeNumber, factor: unknown, options?: object | boolean): string | string[]; } diff --git a/pkg/lib/cockpit.js b/pkg/lib/cockpit.js index 427660de901..79677062ea8 100644 --- a/pkg/lib/cockpit.js +++ b/pkg/lib/cockpit.js @@ -1483,10 +1483,24 @@ function factory() { }); }; - function format_units(number, suffixes, factor, options) { - // backwards compat: "options" argument position used to be a boolean flag "separate" - if (!is_object(options)) - options = { separate: options }; + let deprecated_format_warned = false; + function format_units(suffixes, number, second_arg, third_arg) { + let options = second_arg; + let factor = options?.base2 ? 1024 : 1000; + + // compat API: we used to accept 'factor' as a separate second arg + if (third_arg || (second_arg && !is_object(second_arg))) { + if (!deprecated_format_warned) { + console.warn(`cockpit.format_{bytes,bits}[_per_sec](..., ${second_arg}, ${third_arg}) is deprecated.`); + deprecated_format_warned = true; + } + + factor = second_arg || 1000; + options = third_arg; + // double backwards compat: "options" argument position used to be a boolean flag "separate" + if (!is_object(options)) + options = { separate: options }; + } let suffix = null; @@ -1525,7 +1539,7 @@ function factory() { } } - const string_representation = cockpit.format_number(number, options.precision); + const string_representation = cockpit.format_number(number, options?.precision); let ret; if (string_representation && suffix) @@ -1533,7 +1547,7 @@ function factory() { else ret = [string_representation]; - if (!options.separate) + if (!options?.separate) ret = ret.join(" "); return ret; @@ -1544,36 +1558,8 @@ function factory() { 1024: [null, "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB"] }; - cockpit.format_bytes = function format_bytes(number, factor, options) { - if (factor === undefined) - factor = 1000; - return format_units(number, byte_suffixes, factor, options); - }; - - cockpit.get_byte_units = function get_byte_units(guide_value, factor) { - if (factor === undefined || !(factor in byte_suffixes)) - factor = 1000; - - function unit(index) { - return { - name: byte_suffixes[factor][index], - factor: Math.pow(factor, index) - }; - } - - const units = [unit(2), unit(3), unit(4)]; - - // The default unit is the largest one that gives us at least - // two decimal digits in front of the comma. - - for (let i = units.length - 1; i >= 0; i--) { - if (i === 0 || (guide_value / units[i].factor) >= 10) { - units[i].selected = true; - break; - } - } - - return units; + cockpit.format_bytes = function format_bytes(number, ...args) { + return format_units(byte_suffixes, number, ...args); }; const byte_sec_suffixes = { @@ -1581,20 +1567,16 @@ function factory() { 1024: ["B/s", "KiB/s", "MiB/s", "GiB/s", "TiB/s", "PiB/s", "EiB/s", "ZiB/s"] }; - cockpit.format_bytes_per_sec = function format_bytes_per_sec(number, factor, options) { - if (factor === undefined) - factor = 1000; - return format_units(number, byte_sec_suffixes, factor, options); + cockpit.format_bytes_per_sec = function format_bytes_per_sec(number, ...args) { + return format_units(byte_sec_suffixes, number, ...args); }; const bit_suffixes = { 1000: ["bps", "Kbps", "Mbps", "Gbps", "Tbps", "Pbps", "Ebps", "Zbps"] }; - cockpit.format_bits_per_sec = function format_bits_per_sec(number, factor, options) { - if (factor === undefined) - factor = 1000; - return format_units(number, bit_suffixes, factor, options); + cockpit.format_bits_per_sec = function format_bits_per_sec(number, ...args) { + return format_units(bit_suffixes, number, ...args); }; /* --------------------------------------------------------------------- diff --git a/pkg/lib/qunit-tests.ts b/pkg/lib/qunit-tests.ts index 8dc1e835211..48ba2f1d761 100644 --- a/pkg/lib/qunit-tests.ts +++ b/pkg/lib/qunit-tests.ts @@ -87,4 +87,22 @@ qunitTap(QUnit, function(message: string, ...args: unknown[]) { console.log(message, args); }); +export function f(format: TemplateStringsArray, ...args: unknown[]) { + const strings = [...format].reverse(); + args.reverse(); + + const parts = [strings.pop()]; + if (strings.length !== args.length) { + throw new Error('unequal strings and args in f-string'); + } + + while (args.length !== 0) { + const arg = args.pop(); + parts.push(JSON.stringify(arg) || String(arg)); + parts.push(strings.pop()); + } + + return parts.join(''); +} + export default QUnit; diff --git a/pkg/storaged/dialog.jsx b/pkg/storaged/dialog.jsx index fe5f64f1390..3f33ea00895 100644 --- a/pkg/storaged/dialog.jsx +++ b/pkg/storaged/dialog.jsx @@ -240,7 +240,7 @@ import { show_modal_dialog, apply_modal_dialog } from "cockpit-components-dialog import { ListingTable } from "cockpit-components-table.jsx"; import { FormHelper } from "cockpit-components-form-helper"; -import { fmt_size, block_name, format_size_and_text, format_delay, for_each_async } from "./utils.js"; +import { fmt_size, block_name, format_size_and_text, format_delay, for_each_async, get_byte_units } from "./utils.js"; import { fmt_to_fragments } from "utils.jsx"; import client from "./client.js"; @@ -977,7 +977,7 @@ function size_slider_round(value, round) { class SizeSliderElement extends React.Component { constructor(props) { super(); - this.units = cockpit.get_byte_units(props.value || props.max); + this.units = get_byte_units(props.value || props.max); this.state = { unit: this.units.find(u => u.selected).factor }; } diff --git a/pkg/storaged/test-util.js b/pkg/storaged/test-util.js index 2a53d3d43da..55cb668cc08 100644 --- a/pkg/storaged/test-util.js +++ b/pkg/storaged/test-util.js @@ -18,7 +18,7 @@ */ import * as utils from "./utils.js"; -import QUnit from "qunit-tests"; +import QUnit, { f } from "qunit-tests"; QUnit.test("format_delay", function (assert) { const checks = [ @@ -81,6 +81,79 @@ QUnit.test("mdraid_name_local_transient", function (assert) { utils.mock_hostnamed(null); }); +QUnit.test("get_byte_units", function (assert) { + const mb = 1000 * 1000; + const gb = mb * 1000; + const tb = gb * 1000; + + const mb_unit = { factor: mb, name: "MB" }; + const gb_unit = { factor: gb, name: "GB" }; + const tb_unit = { factor: tb, name: "TB" }; + + function selected(unit) { + return { factor: unit.factor, name: unit.name, selected: true }; + } + + const checks = [ + [0 * mb, [selected(mb_unit), gb_unit, tb_unit]], + [20 * mb, [selected(mb_unit), gb_unit, tb_unit]], + [200 * mb, [selected(mb_unit), gb_unit, tb_unit]], + [2000 * mb, [selected(mb_unit), gb_unit, tb_unit]], + [20000 * mb, [mb_unit, selected(gb_unit), tb_unit]], + [20 * gb, [mb_unit, selected(gb_unit), tb_unit]], + [200 * gb, [mb_unit, selected(gb_unit), tb_unit]], + [2000 * gb, [mb_unit, selected(gb_unit), tb_unit]], + [20000 * gb, [mb_unit, gb_unit, selected(tb_unit)]] + ]; + + assert.expect(checks.length); + for (let i = 0; i < checks.length; i++) { + assert.deepEqual(utils.get_byte_units(checks[i][0]), checks[i][1], + "get_byte_units(" + checks[i][0] + ") = " + JSON.stringify(checks[i][1])); + } +}); + +QUnit.test("format_fsys_usage", function (assert) { + const [k, M, G, T] = [1_000, 1_000_000, 1_000_000_000, 1_000_000_000_000]; + + const sizes = [5, 200, 5 * k, 200 * k, 5 * M, 200 * M, 5 * G, 200 * G, 5 * T, 200 * T]; + /* For each "total" size, format all of the "used" sizes less than or equal to it. + * The results table lists the part that should come after and before the slash, respectively. + * For example: ["5 KB", ["0.01", "0.20", "5"]] + * means 5, 200 and 5k out of 5k are displayed as "0.01 / 5KB", "0.20 / 5KB" and "5 / 5KB" + */ + const results = [ + ["5", ["5"]], + ["200", ["5", "200"]], + ["5 KB", ["0.01", "0.20", "5"]], + ["200 KB", ["0.01", "0.20", "5", "200"]], + ["5 MB", ["0.01", "0.01", "0.01", "0.20", "5"]], + ["200 MB", ["0.01", "0.01", "0.01", "0.20", "5", "200"]], + ["5 GB", ["0.01", "0.01", "0.01", "0.01", "0.01", "0.20", "5"]], + ["200 GB", ["0.01", "0.01", "0.01", "0.01", "0.01", "0.20", "5", "200"]], + ["5 TB", ["0.01", "0.01", "0.01", "0.01", "0.01", "0.01", "0.01", "0.20", "5"]], + ["200 TB", ["0.01", "0.01", "0.01", "0.01", "0.01", "0.01", "0.01", "0.20", "5", "200"]], + ]; + + for (let total_i = 0; total_i < results.length; total_i++) { + const [total_string, used_strings] = results[total_i]; + assert.strictEqual(used_strings.length, total_i + 1); + for (let used_i = 0; used_i < used_strings.length; used_i++) { + const used_string = used_strings[used_i]; + + const used = sizes[used_i]; + const total = sizes[total_i]; + const expected_string = used_string + " / " + total_string; + + assert.strictEqual( + utils.format_fsys_usage(used, total), + expected_string, + f`format_fsys_usage(${used}, ${total})` + ); + } + } +}); + /* Wait until the hostnamed dbus proxy is actually ready; otherwise the test * finishes and kills the bridge before it can respond to the dbus channel open * request for the hostnamed connection, which can cause hangs in diff --git a/pkg/storaged/utils.js b/pkg/storaged/utils.js index f09a444f257..600af8b49b7 100644 --- a/pkg/storaged/utils.js +++ b/pkg/storaged/utils.js @@ -1140,3 +1140,16 @@ export function get_mount_points(client, block_fsys, subvol) { return mounted_at; } + +export function get_byte_units(guide_value) { + const units = [ + { factor: 1000 ** 2, name: "MB" }, + { factor: 1000 ** 3, name: "GB" }, + { factor: 1000 ** 4, name: "TB" }, + ]; + // Find the biggest unit which gives two digits left of the decimal point (>= 10) + const unit = units.findLastIndex(unit => guide_value / unit.factor >= 10); + // Mark it selected. If we couldn't find one (-1), then use MB. + units[Math.max(0, unit)].selected = true; + return units; +}