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" /> );