web-console/src/utils/general.tsx (580 lines of code) (raw):
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Classes, Icon, Intent } from '@blueprintjs/core';
import type { IconName } from '@blueprintjs/icons';
import { IconNames } from '@blueprintjs/icons';
import copy from 'copy-to-clipboard';
import * as JSONBig from 'json-bigint-native';
import numeral from 'numeral';
import type { JSX } from 'react';
import { AppToaster } from '../singletons';
// These constants are used to make sure that they are not constantly recreated thrashing the pure components
export const EMPTY_OBJECT: any = {};
export const EMPTY_ARRAY: any[] = [];
export type NumberLike = number | bigint;
export function isNumberLike(x: unknown): x is NumberLike {
const t = typeof x;
return t === 'number' || t === 'bigint';
}
export function isNumberLikeNaN(x: NumberLike): boolean {
return isNaN(Number(x));
}
export function nonEmptyString(s: unknown): s is string {
return typeof s === 'string' && s !== '';
}
export function nonEmptyArray(a: unknown): a is unknown[] {
return Array.isArray(a) && Boolean(a.length);
}
export function isSimpleArray(a: any): a is (string | number | boolean)[] {
return (
Array.isArray(a) &&
a.every(x => {
const t = typeof x;
return t === 'string' || t === 'number' || t === 'boolean';
})
);
}
export function arraysEqualByElement<T>(xs: T[], ys: T[]): boolean {
return xs.length === ys.length && xs.every((x, i) => x === ys[i]);
}
export function wait(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
export function clamp(n: number, min = -Infinity, max = Infinity): number {
return Math.min(Math.max(n, min), max);
}
export function addOrUpdate<T>(xs: readonly T[], x: T, keyFn: (x: T) => string | number): T[] {
const keyX = keyFn(x);
let added = false;
const newXs = xs.map(currentX => {
if (keyFn(currentX) === keyX) {
added = true;
return x;
} else {
return currentX;
}
});
if (!added) {
newXs.push(x);
}
return newXs;
}
// ----------------------------
export function caseInsensitiveEquals(str1: string | undefined, str2: string | undefined): boolean {
return str1?.toLowerCase() === str2?.toLowerCase();
}
export function caseInsensitiveContains(testString: string, searchString: string): boolean {
if (!searchString) return true;
return testString.toLowerCase().includes(searchString.toLowerCase());
}
function validateKnown<T>(allKnownValues: T[], options: T[]): void {
options.forEach(o => {
if (!allKnownValues.includes(o)) {
throw new Error(`allKnownValues (${allKnownValues.join(', ')}) must include '${o}'`);
}
});
}
export function oneOf<T>(value: T, ...options: T[]): boolean {
return options.includes(value);
}
export function oneOfKnown<T>(value: T, allKnownValues: T[], ...options: T[]): boolean | undefined {
validateKnown(allKnownValues, options);
if (options.includes(value)) return true;
return allKnownValues.includes(value) ? false : undefined;
}
export function typeIs<T extends { type?: S }, S = string>(...options: S[]): (x: T) => boolean {
return x => {
if (x.type == null) return false;
return options.includes(x.type);
};
}
export function typeIsKnown<T extends { type?: S }, S = string>(
allKnownValues: S[],
...options: S[]
): (x: T) => boolean | undefined {
validateKnown(allKnownValues, options);
return x => {
const value = x.type;
if (value == null) return;
if (options.includes(value)) return true;
return allKnownValues.includes(value) ? false : undefined;
};
}
export function without<T>(xs: readonly T[], x: T | undefined): T[] {
return xs.filter(i => i !== x);
}
export function change<T>(xs: readonly T[], from: T, to: T): T[] {
return xs.map(x => (x === from ? to : x));
}
// ----------------------------
export function countBy<T>(
array: readonly T[],
fn: (x: T, index: number) => string | number = String,
): Record<string, number> {
const counts: Record<string, number> = {};
for (let i = 0; i < array.length; i++) {
const key = fn(array[i], i);
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
function identity<T>(x: T): T {
return x;
}
export function lookupBy<T, Q = T>(
array: readonly T[],
keyFn: (x: T, index: number) => string | number = String,
valueFn?: (x: T, index: number) => Q,
): Record<string, Q> {
if (!valueFn) valueFn = identity as any;
const lookup: Record<string, Q> = {};
const n = array.length;
for (let i = 0; i < n; i++) {
const a = array[i];
lookup[keyFn(a, i)] = valueFn!(a, i);
}
return lookup;
}
export function mapRecord<T, Q>(
record: Record<string, T>,
fn: (value: T, key: string) => Q,
): Record<string, Q> {
const newRecord: Record<string, Q> = {};
const keys = Object.keys(record);
for (const key of keys) {
const mapped = fn(record[key], key);
if (typeof mapped === 'undefined') continue;
newRecord[key] = mapped;
}
return newRecord;
}
export function mapRecordOrReturn<T>(
record: Record<string, T>,
fn: (value: T, key: string) => T | undefined,
): Record<string, T> {
const newRecord: Record<string, T> = {};
let changed = false;
const keys = Object.keys(record);
for (const key of keys) {
const v = record[key];
const mapped = fn(v, key);
if (v !== mapped) changed = true;
if (typeof mapped === 'undefined') continue;
newRecord[key] = mapped;
}
return changed ? newRecord : record;
}
export function groupBy<T, Q>(
array: readonly T[],
keyFn: (x: T, index: number) => string,
aggregateFn: (xs: readonly T[], key: string) => Q,
): Q[] {
const buckets: Record<string, T[]> = {};
const n = array.length;
for (let i = 0; i < n; i++) {
const value = array[i];
const key = keyFn(value, i);
buckets[key] = buckets[key] || [];
buckets[key].push(value);
}
return Object.entries(buckets).map(([key, xs]) => aggregateFn(xs, key));
}
export function groupByAsMap<T, Q>(
array: readonly T[],
keyFn: (x: T, index: number) => string | number,
aggregateFn: (xs: readonly T[], key: string) => Q,
): Record<string, Q> {
const buckets: Record<string, T[]> = {};
const n = array.length;
for (let i = 0; i < n; i++) {
const value = array[i];
const key = keyFn(value, i);
buckets[key] = buckets[key] || [];
buckets[key].push(value);
}
return mapRecord(buckets, aggregateFn);
}
export function uniq(array: readonly string[]): string[] {
const seen: Record<string, boolean> = {};
return array.filter(s => {
if (Object.hasOwn(seen, s)) {
return false;
} else {
seen[s] = true;
return true;
}
});
}
export function allSameValue<T>(xs: readonly T[]): T | undefined {
const sameValue: T | undefined = xs[0];
for (let i = 1; i < xs.length; i++) {
if (sameValue !== xs[i]) return;
}
return sameValue;
}
// ----------------------------
export function formatEmpty(str: string): string {
return str === '' ? 'empty' : str;
}
// ----------------------------
export function formatInteger(n: NumberLike): string {
return numeral(n).format('0,0');
}
export function formatNumber(n: NumberLike): string {
return (n || 0).toLocaleString('en-US', { maximumFractionDigits: 20 });
}
export function formatNumberAbbreviated(n: NumberLike): string {
return (n || 0).toLocaleString('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 2,
});
}
export function formatRate(n: NumberLike) {
return numeral(n).format('0,0.0') + '/s';
}
export function formatBytes(n: NumberLike): string {
return numeral(n).format('0.00 b');
}
export function formatByteRate(n: NumberLike): string {
return numeral(n).format('0.00 b') + '/s';
}
export function formatBytesCompact(n: NumberLike): string {
return numeral(n).format('0.00b');
}
export function formatByteRateCompact(n: NumberLike): string {
return numeral(n).format('0.00b') + '/s';
}
export function formatMegabytes(n: NumberLike): string {
return numeral(Number(n) / 1048576).format('0,0.0');
}
export function formatPercent(n: NumberLike): string {
return (Number(n) * 100).toFixed(2) + '%';
}
export function formatPercentClapped(n: NumberLike): string {
return formatPercent(Math.min(Math.max(Number(n), 0), 1));
}
export function formatMillions(n: NumberLike): string {
const s = (Number(n) / 1e6).toFixed(3);
if (s === '0.000') return String(Math.round(Number(n)));
return s + ' M';
}
export function forceSignInNumberFormatter(
formatter: (n: NumberLike) => string,
): (n: NumberLike) => string {
return (n: NumberLike) => {
if (n > 0) {
return '+' + formatter(n);
} else {
return formatter(n);
}
};
}
function sign(n: NumberLike): string {
return n < 0 ? '-' : '';
}
function pad2(str: string | number): string {
return ('00' + str).slice(-2);
}
function pad3(str: string | number): string {
return ('000' + str).slice(-3);
}
export function formatDuration(ms: NumberLike): string {
const n = Math.abs(Number(ms));
const timeInHours = Math.floor(n / 3600000);
const timeInMin = Math.floor(n / 60000) % 60;
const timeInSec = Math.floor(n / 1000) % 60;
return sign(ms) + timeInHours + ':' + pad2(timeInMin) + ':' + pad2(timeInSec);
}
export function formatDurationWithMs(ms: NumberLike): string {
const n = Math.abs(Number(ms));
const timeInHours = Math.floor(n / 3600000);
const timeInMin = Math.floor(n / 60000) % 60;
const timeInSec = Math.floor(n / 1000) % 60;
return (
sign(ms) +
timeInHours +
':' +
pad2(timeInMin) +
':' +
pad2(timeInSec) +
'.' +
pad3(Math.floor(n) % 1000)
);
}
export function formatDurationWithMsIfNeeded(ms: NumberLike): string {
return Number(ms) < 1000 ? formatDurationWithMs(ms) : formatDuration(ms);
}
export function formatDurationHybrid(ms: NumberLike): string {
const n = Math.abs(Number(ms));
if (n < 600000) {
// anything that looks like 1:23.45 (max 9:59.99)
const timeInMin = Math.floor(n / 60000);
const timeInSec = Math.floor(n / 1000) % 60;
const timeInMs = Math.floor(n) % 1000;
return `${sign(ms)}${timeInMin ? `${timeInMin}:` : ''}${
timeInMin ? pad2(timeInSec) : timeInSec
}.${pad3(timeInMs).slice(0, 2)}s`;
} else {
return formatDuration(ms);
}
}
export function timezoneOffsetInMinutesToString(offsetInMinutes: number, padHour: boolean): string {
const sign = offsetInMinutes < 0 ? '-' : '+';
const absOffset = Math.abs(offsetInMinutes);
const h = Math.floor(absOffset / 60);
const m = absOffset % 60;
return `${sign}${padHour ? pad2(h) : h}:${pad2(m)}`;
}
function pluralize(word: string): string {
// Ignoring irregular plurals.
if (/(s|x|z|ch|sh)$/.test(word)) {
return word + 'es';
} else if (/([^aeiou])y$/.test(word)) {
return word.slice(0, -1) + 'ies';
} else if (/(f|fe)$/.test(word)) {
return word.replace(/fe?$/, 'ves');
} else {
return word + 's';
}
}
export function pluralIfNeeded(n: NumberLike, singular: string, plural?: string): string {
if (!plural) plural = pluralize(singular);
return `${formatInteger(n)} ${n === 1 ? singular : plural}`;
}
// ----------------------------
export function partition<T>(xs: T[], predicate: (x: T, i: number) => boolean): [T[], T[]] {
const match: T[] = [];
const nonMatch: T[] = [];
for (let i = 0; i < xs.length; i++) {
const x = xs[i];
if (predicate(x, i)) {
match.push(x);
} else {
nonMatch.push(x);
}
}
return [match, nonMatch];
}
export function filterMap<T, Q>(xs: readonly T[], f: (x: T, i: number) => Q | undefined): Q[] {
return xs.map(f).filter((x: Q | undefined) => typeof x !== 'undefined') as Q[];
}
export function filterMapOrReturn<T>(
xs: readonly T[],
f: (x: T, i: number) => T | undefined,
): readonly T[] {
let changed = false;
const newXs = filterMap(xs, (x, i) => {
const newX = f(x, i);
if (typeof newX === 'undefined' || x !== newX) changed = true;
return newX;
});
return changed ? newXs : xs;
}
export function filterOrReturn<T>(xs: readonly T[], f: (x: T, i: number) => unknown): readonly T[] {
const newXs = xs.filter(f);
return newXs.length === xs.length ? xs : newXs;
}
export function findMap<T, Q>(
xs: readonly T[],
f: (x: T, i: number) => Q | undefined,
): Q | undefined {
return filterMap(xs, f)[0];
}
export function minBy<T>(xs: T[], f: (item: T, index: number) => number): T | undefined {
if (!xs.length) return undefined;
let minItem = xs[0];
let minValue = f(xs[0], 0);
for (let i = 1; i < xs.length; i++) {
const currentValue = f(xs[i], i);
if (currentValue < minValue) {
minValue = currentValue;
minItem = xs[i];
}
}
return minItem;
}
export function changeByIndex<T>(
xs: readonly T[],
i: number,
f: (x: T, i: number) => T | undefined,
): T[] {
return filterMap(xs, (x, j) => (j === i ? f(x, i) : x));
}
export function compact<T>(xs: (T | undefined | false | null | '')[]): T[] {
return xs.filter(Boolean) as T[];
}
export function assemble<T>(...xs: (T | undefined | false | null | '')[]): T[] {
return compact(xs);
}
export function removeUndefinedValues<T extends Record<string, any>>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => value !== undefined),
) as Partial<T>;
}
export function moveToEnd<T>(
xs: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
): T[] {
return xs.filter((x, i, a) => !predicate(x, i, a)).concat(xs.filter(predicate));
}
export function alphanumericCompare(a: string, b: string): number {
return String(a).localeCompare(b, undefined, { numeric: true });
}
export function zeroDivide(a: number, b: number): number {
if (b === 0) return 0;
return a / b;
}
export function capitalizeFirst(str: string): string {
return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase();
}
export function arrangeWithPrefixSuffix(
things: readonly string[],
prefix: readonly string[],
suffix: readonly string[],
): string[] {
const pre = uniq(prefix.filter(x => things.includes(x)));
const mid = things.filter(x => !prefix.includes(x) && !suffix.includes(x));
const post = uniq(suffix.filter(x => things.includes(x)));
return pre.concat(mid, post);
}
// ----------------------------
export function copyAndAlert(copyString: string, alertMessage: string): void {
copy(copyString, { format: 'text/plain' });
AppToaster.show({
message: alertMessage,
intent: Intent.SUCCESS,
});
}
export function delay(ms: number) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
export function swapElements<T>(items: readonly T[], indexA: number, indexB: number): T[] {
const newItems = items.concat();
const t = newItems[indexA];
newItems[indexA] = newItems[indexB];
newItems[indexB] = t;
return newItems;
}
export function moveElement<T>(items: readonly T[], fromIndex: number, toIndex: number): T[] {
const indexDiff = fromIndex - toIndex;
if (indexDiff > 0) {
// move left
return [
...items.slice(0, toIndex),
items[fromIndex],
...items.slice(toIndex, fromIndex),
...items.slice(fromIndex + 1, items.length),
];
} else if (indexDiff < 0) {
// move right
const targetIndex = toIndex + 1;
return [
...items.slice(0, fromIndex),
...items.slice(fromIndex + 1, targetIndex),
items[fromIndex],
...items.slice(targetIndex, items.length),
];
} else {
// do nothing
return items.slice();
}
}
export function moveToIndex<T>(
items: readonly T[],
itemToIndex: (item: T, i: number) => number,
): T[] {
const frontItems: { item: T; index: number }[] = [];
const otherItems: T[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const index = itemToIndex(item, i);
if (index >= 0) {
frontItems.push({ item, index });
} else {
otherItems.push(item);
}
}
return frontItems
.sort((a, b) => a.index - b.index)
.map(d => d.item)
.concat(otherItems);
}
export function stringifyValue(value: unknown): string {
switch (typeof value) {
case 'object':
if (!value) return String(value);
if (typeof (value as any).toISOString === 'function') return (value as any).toISOString();
return JSONBig.stringify(value);
default:
return String(value);
}
}
export function isInBackground(): boolean {
return document.visibilityState === 'hidden';
}
export function twoLines(line1: string | JSX.Element, line2: string | JSX.Element) {
return (
<>
{line1}
<br />
{line2}
</>
);
}
export function parseCsvLine(line: string): string[] {
line = ',' + line.replace(/\r?\n?$/, ''); // remove trailing new lines
const parts: string[] = [];
let m: RegExpExecArray | null;
while ((m = /^,(?:"([^"]*(?:""[^"]*)*)"|([^,\r\n]*))/m.exec(line))) {
parts.push(typeof m[1] === 'string' ? m[1].replace(/""/g, '"') : m[2]);
line = line.slice(m[0].length);
}
return parts;
}
// From: https://en.wikipedia.org/wiki/Jenkins_hash_function
export function hashJoaat(str: string): number {
let hash = 0;
const n = str.length;
for (let i = 0; i < n; i++) {
hash += str.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash += hash << 10;
// eslint-disable-next-line no-bitwise
hash ^= hash >> 6;
}
// eslint-disable-next-line no-bitwise
hash += hash << 3;
// eslint-disable-next-line no-bitwise
hash ^= hash >> 11;
// eslint-disable-next-line no-bitwise
hash += hash << 15;
// eslint-disable-next-line no-bitwise
return (hash & 4294967295) >>> 0;
}
export const OVERLAY_OPEN_SELECTOR = `.${Classes.PORTAL} .${Classes.OVERLAY_OPEN}`;
export function hasOverlayOpen(): boolean {
return Boolean(document.querySelector(OVERLAY_OPEN_SELECTOR));
}
export function checkedCircleIcon(checked: boolean, exclude?: boolean): IconName {
return checked ? (exclude ? IconNames.CROSS_CIRCLE : IconNames.TICK_CIRCLE) : IconNames.CIRCLE;
}
export function tickIcon(checked: boolean): IconName {
return checked ? IconNames.TICK : IconNames.BLANK;
}
export function generate8HexId(): string {
return (Math.random() * 1e10).toString(16).replace('.', '').slice(0, 8);
}
export interface RowColumn {
row: number;
column: number;
}
export function offsetToRowColumn(str: string, offset: number): RowColumn | undefined {
// Ensure offset is within the string length
if (offset < 0 || offset > str.length) return;
const lines = str.split('\n');
for (let row = 0; row < lines.length; row++) {
const line = lines[row];
if (offset <= line.length) {
return {
row,
column: offset,
};
}
offset -= line.length + 1;
}
return;
}
export function xor(a: unknown, b: unknown): boolean {
return Boolean(a ? !b : b);
}
export function toggle<T>(xs: readonly T[], x: T, eq?: (a: T, b: T) => boolean): T[] {
const e = eq || ((a, b) => a === b);
return xs.find(_ => e(_, x)) ? xs.filter(d => !e(d, x)) : xs.concat([x]);
}
export const EXPERIMENTAL_ICON = (
<Icon icon={IconNames.LAB_TEST} intent={Intent.WARNING} data-tooltip="Experimental" />
);