/* * Copyright (c) [2022-2026] SUSE LLC * * All Rights Reserved. * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation; either version 2 of the License, or (at your option) * any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, contact SUSE LLC. * * To contact SUSE LLC about this file by physical or electronic mail, you may * find current contact information at www.suse.com. */ import { isArray, isPlainObject, mapEntries } from "radashi"; import { generatePath } from "react-router"; import { ISortBy, sort } from "fast-sort"; import { _ } from "~/i18n"; import type { MarkedString, TranslatedString } from "~/i18n"; /** * Generates a new array without null and undefined values. */ const compact = (collection: Array) => { return collection.filter((e) => e !== null && e !== undefined); }; /** * Parses a "numeric dot string" as a hexadecimal number. * * Accepts only strings containing hexadecimal numbers (`0–9a-fA-F`) and dots (`.`), * for example: `"0.0.0160"` `"0.0.019d"` or `"123"`. Dots are removed before parsing. * * If the cleaned string contains any non-digit characters (such as letters), * or is not a valid integer string, the function returns `0`. * * @example * * ```ts * hex("0.0.0.160"); // Returns 352 * hex("0.0.0.19d"); // Returns 352 * hex("1.2.3"); // Returns 291 * hex(".."); // Returns 0 (empty string before removing dots) * ``` * * @param value - A string representing a dot-separated numeric value * @returns The number parsed as hexadecimal (base-16) integer, or `0` if invalid */ const hex = (value: string): number => { const sanitizedValued = value.replaceAll(".", ""); return /^[0-9a-fA-F]+$/.test(sanitizedValued) ? parseInt(sanitizedValued, 16) : 0; }; /** * Wrapper around window.location.reload * * It's needed mainly to ease testing because we can't override window in jest with jsdom anymore * * See below links * - https://github.com/jsdom/jsdom/blob/master/Changelog.md#2100 * - https://github.com/jsdom/jsdom/issues/3492 */ const locationReload = () => { window.location.reload(); }; /** * Wrapper around window.location.search setter * * It's needed mainly to ease testing as we can't override window in jest with jsdom anymore * * See below links * - https://github.com/jsdom/jsdom/blob/master/Changelog.md#2100 * - https://github.com/jsdom/jsdom/issues/3492 * * @param query */ const setLocationSearch = (query: string) => { window.location.search = query; }; /** * Whether Agama server is running locally or not. * * This function should be used only in special cases, the Agama behavior should * be the same regardless of the user connection. * * The local connection can be forced by setting the `LOCAL_CONNECTION` * environment variable to `1`. This can be useful for debugging or for * development. * * @returns `true` if the connection is local, `false` otherwise */ const localConnection = (location: Location | URL = window.location) => { // forced local behavior if (process.env.LOCAL_CONNECTION === "1") return true; const hostname = location.hostname; // using the loopback device? (hostname or IP address) return hostname === "localhost" || hostname.startsWith("127."); }; const dateTimeFormatters = new Map(); /** * Cached `Intl.DateTimeFormat` for a time zone and options. * * Constructing a formatter is the costly part; formatting with it is cheap. Reuse * instances across calls so building a long time-zone list (or rebuilding it when * a selector opens) does not reconstruct hundreds of formatters. * * The locale is fixed to "en-US" so the output stays locale-neutral (24h time, * "UTC+1"), which is why the cache key is just the time zone and options. If the * locale is ever made dynamic, it must be added to the cache key too, otherwise a * language change would reuse a stale-locale formatter. * * @param timezone - E.g., "Atlantic/Canary". * @param options - Formatting options (e.g. `{ timeStyle: "short" }`). */ const dateTimeFormat = ( timezone: string, options: Intl.DateTimeFormatOptions, ): Intl.DateTimeFormat => { const key = `${timezone}|${JSON.stringify(options)}`; let formatter = dateTimeFormatters.get(key); if (!formatter) { formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, ...options }); dateTimeFormatters.set(key, formatter); } return formatter; }; /** Clears the cached formatters. Intended for tests that mock `Intl.DateTimeFormat`. */ const clearDateTimeFormatCache = (): void => { dateTimeFormatters.clear(); }; /** * Time for the given timezone. * * @param timezone - E.g., "Atlantic/Canary". * @param date - Date to take the time from. * * @returns Time in 24 hours format (e.g., "23:56"). Undefined for an unknown timezone. */ const timezoneTime = (timezone: string, date: Date = new Date()): string | undefined => { try { return dateTimeFormat(timezone, { timeStyle: "short", hour12: false }).format(date); } catch (e) { if (e instanceof RangeError) return undefined; throw e; } }; /** * Masks all but the last `visible` characters of a string. * * Replaces each character in the input string with `maskChar` ("*" by default), * except for the last `visible` (4 by default) characters, which are left * unchanged. If `visible` is greater than or equal to the string length, the * input is returned as-is. * * @example * ```ts * mask("123456789"); // "*****6789" * mask("secret", 2); // "****et" * mask("secret", 6); // "secret" * mask("secret", 3, "#"); // "###ret" * ``` * * @param value - The input string to mask * @param visible - Number of trailing characters to leave unmasked (default: 4) * @param maskChar - The character to use for masking (default: "*") * @returns The masked string with only the last `visible` characters shown */ const mask = (value: string, visible: number = 4, maskChar: string = "*"): string => { const length = value.length; const safeVisible = Number.isFinite(visible) && visible > 0 ? visible : 0; const maskedLength = Math.max(0, length - safeVisible); const visiblePart = safeVisible === 0 ? "" : value.slice(-safeVisible); return maskChar.repeat(maskedLength) + visiblePart; }; // list of sensitive object properties replaced by the maskSecrets() function const defaultSensitiveKeys = [ // storage "encryptionPassword", // users "password", // registration "registrationCode", // storage (iSCSI setting) "reverse_password", ]; /** * Recursively filters an object by replacing sensitive values with text * "[FILTERED]". Useful for logging possibly sensitive data. * * It returns a new object, the original input is unchanged. * * @example * ```ts * const data = { user: "John", password: "123" }; * // logs '{ "user": "John", "password": "[FILTERED]" }' * console.log(maskSecrets(data)); * ``` * * @param obj - The object or array to sanitize * @param options - Options object { sensitiveKeys, stringify } * @returns Sanitized copy of the input */ const maskSecrets = ( obj: unknown, { sensitiveKeys = defaultSensitiveKeys, stringify = true, }: { sensitiveKeys?: string[]; stringify?: boolean } = {}, ): unknown => { const mask = (currentObj: unknown): unknown => { if (isArray(currentObj)) { return currentObj.map(mask); } return isPlainObject(currentObj) ? mapEntries(currentObj, (k, v) => [k, sensitiveKeys.includes(k) ? "[FILTERED]" : mask(v)]) : currentObj; }; const result = mask(obj); if (stringify) { return JSON.stringify(result, null, 2); } return result; }; /** * A wrapper around React Router's `generatePath` that ensures all path parameters * are URI-encoded using `encodeURIComponent`. This prevents broken URLs caused by * special characters such as spaces, `#`, `$`, and others. * * @example * ```ts * // Returns "/network/Wired%20%231" * generateEncodedPath("/network/:id", { id: "Wired #1" }); * ``` */ const generateEncodedPath = (...args: Parameters) => { const [path, params] = args; return generatePath( path, mapEntries(params, (key, value) => [key, encodeURIComponent(value)]), ); }; /** * A lightweight wrapper around `fast-sort`. * * Rather than using `fast-sort`'s method-chaining syntax, this function accepts * the sort direction (`"asc"` or `"desc"`) as a direct argument, resulting in a * cleaner and more declarative API for Agama components, where sorting is often * built dynamically. * * @example * ```ts * sortCollection(devices, 'asc', size'); * sortCollection(devices, 'desc', d => d.sid + d.size); * sortCollection(devices, 'asc', [d => d.size, d => d.name]); * ``` * @param collection - The array of items to be sorted. * @param direction - The direction of the sort. Use "asc" for ascending or * "desc" for descending. * @param key - The key (as a string) to sort by, or a custom function * compatible with `fast-sort`'s ISortBy. * * @returns A new array sorted based on the given key and direction. * */ const sortCollection = (collection: T[], direction: "asc" | "desc", key: string | ISortBy) => sort(collection)[direction](key as ISortBy); /** Options for mergeSources */ export type MergeSourcesOptions = { /** Object mapping source names to their arrays */ collections: Record; /** Order of precedence of where to take the object from if it is in more than * one collection */ precedence: string[]; /** The property name(s) to use as the unique identifier */ primaryKey?: K | K[]; }; /** Item augmented with sources array */ type ItemWithSources = Omit & { /** Array of source names where this item was found */ sources: string[]; }; /** * Merges multiple collections of objects, tracking which sources each item * appears in. * * When the same item (identified by the specified primaryKey(s)) appears in multiple * collections, it will appear only once in the output with a `sources` array * listing all collections where it was found. The object data is taken from * the first source in the precedence order where it appears. * * @template T - The type of objects in the collections * @template K - The key type used for identifying unique items * * @param options - Configuration object * @param options.collections - Object mapping source names to arrays of items * @param options.precedence - Array of source names in priority order (first = highest) * @param options.primaryKey - Property name(s) to use as unique identifier (default: "id"). * Can be a single key or array of keys for composite primary keys. * * @returns Array of merged items, each with a `sources` property * * @example * ```typescript * // Single primary key example - iSCSI targets identified by name only * const result = mergeSources({ * collections: { * system: [ * { * name: "iqn.2023-01.com.example:12ac588", * address: "192.168.100.102", * port: 3262, * interface: "default", * ibtf: false, * startup: "onboot", * connected: true, * locked: false * } * ], * config: [ * { * name: "iqn.2023-01.com.example:12ac588", * address: "192.168.100.102", * port: 3262, * interface: "default", * startup: "onboot" * }, * { * name: "iqn.2023-01.com.example:12ac788", * address: "192.168.100.106", * port: 3264, * interface: "default", * startup: "onboot" * } * ] * }, * precedence: ['system', 'config'], * primaryKey: 'name' * }); * // Returns: * // [ * // { * // name: "iqn.2023-01.com.example:12ac588", * // address: "192.168.100.102", * // port: 3262, * // interface: "default", * // ibtf: false, * // startup: "onboot", * // connected: true, * // locked: false, * // sources: ['system', 'config'] * // }, * // { * // name: "iqn.2023-01.com.example:12ac788", * // address: "192.168.100.106", * // port: 3264, * // interface: "default", * // startup: "onboot", * // sources: ['config'] * // } * // ] * * // Multiple primary key example - iSCSI targets identified by name + address + port * // This is necessary because the same target name can have multiple portals * const result2 = mergeSources({ * collections: { * system: [ * { * name: "iqn.2023-01.com.example:storage", * address: "192.168.100.102", * port: 3260, * interface: "default", * ibtf: false, * startup: "onboot", * connected: true, * locked: false * }, * { * name: "iqn.2023-01.com.example:storage", * address: "192.168.100.102", * port: 3261, * interface: "default", * ibtf: false, * startup: "manual", * connected: false, * locked: false * } * ], * config: [ * { * name: "iqn.2023-01.com.example:storage", * address: "192.168.100.102", * port: 3260, * interface: "default", * startup: "onboot" * }, * { * name: "iqn.2023-01.com.example:storage", * address: "192.168.100.102", * port: 3261, * interface: "default", * startup: "manual" * }, * { * name: "iqn.2023-01.com.example:storage", * address: "192.168.100.103", * port: 3260, * interface: "default", * startup: "onboot" * } * ] * }, * precedence: ['system', 'config'], * primaryKey: ['name', 'address', 'port'] * }); * // Returns: * // [ * // { * // name: "iqn.2023-01.com.example:storage", * // address: "192.168.100.102", * // port: 3260, * // interface: "default", * // ibtf: false, * // startup: "onboot", * // connected: true, * // locked: false, * // sources: ['system', 'config'] * // }, * // { * // name: "iqn.2023-01.com.example:storage", * // address: "192.168.100.102", * // port: 3261, * // interface: "default", * // ibtf: false, * // startup: "manual", * // connected: false, * // locked: false, * // sources: ['system', 'config'] * // }, * // { * // name: "iqn.2023-01.com.example:storage", * // address: "192.168.100.103", * // port: 3260, * // interface: "default", * // startup: "onboot", * // sources: ['config'] * // } * // ] * ``` */ function mergeSources({ collections, precedence, primaryKey = "id" as K, }: MergeSourcesOptions): ItemWithSources[] { const map = new Map>(); for (const name of precedence) { const items = collections[name]; for (const obj of items) { const key = Array.isArray(primaryKey) ? primaryKey.map((k) => String(obj[k])).join("|") : String(obj[primaryKey]); if (map.has(key)) { map.get(key)!.sources.push(name); } else { map.set(key, { ...obj, sources: [name] }); } } } return Array.from(map.values()); } /** * Options for extending a base collection with matching merge items. */ interface ExtendCollectionOptions { /** * Collection providing merge data to enrich base items. * * Items in this collection will be matched against the base collection * according to the `matching` fields. */ with?: U[]; /** * Field name or array of field names used to match items between the base * collection and the merge collection. * - Single field: matches on that field. * - Multiple fields: all fields are combined to determine a match. */ matching?: keyof T | (keyof T)[]; /** * Determines which collection's properties take priority when merging: * - `"baseWins"`: base item properties overwrite merge properties. * - `"extensionWins"`: merge properties overwrite base item properties. * @default "baseWins" */ precedence?: "baseWins" | "extensionWins"; /** * Controls how array properties are merged: * - `true`: Merge all array properties by concatenating values from both sources * - `string[]`: Merge only the specified array property names * - `false` or `undefined`: Use precedence rules (default behavior) * * When merging arrays, values from the extension are added to the base array, * with duplicates removed using strict equality. * * @example * ```ts * // Merge all arrays * extendCollection( * [{ id: "a", addresses: [], nameservers: [] }], * { * with: [{ id: "a", addresses: ["192.168.1.1"], nameservers: ["8.8.8.8"] }], * mergeArrays: true * } * ) * // => addresses: ["192.168.1.1"], nameservers: ["8.8.8.8"] * * // Merge specific arrays only * extendCollection( * [{ id: "a", addresses: [], tags: ["tag1"] }], * { * with: [{ id: "a", addresses: ["192.168.1.1"], tags: ["tag2"] }], * mergeArrays: ["addresses"] * } * ) * // => addresses: ["192.168.1.1"], tags: ["tag1"] (precedence wins for tags) * ``` */ mergeArrays?: boolean | string[]; } /** * Result of extending a collection. */ interface ExtendCollectionResult { /** * Items from the base collection, extended with matching extension data. * * Items without matches are returned unchanged. */ extended: (T & U)[]; /** * Items from the extension collection that did not match any base item. */ unmatched: U[]; /** * All items: base collection (extended where matched) + unmatched extension * items appended at the end, preserving base collection order. */ all: (T & U)[]; } /** * Extends a base collection of items by merging in matching items from an * extension collection. * * For each item in the base collection: * 1. Find a matching item in the extension collection based on the specified * field(s). * 2. Merge the matched item's properties into the base item according to * `precedence`. * * @returns Object containing: * - `extended`: base collection items, merged where a match was found. * - `unmatched`: extension items that had no matching base item. * - `all`: `extended` items followed by `unmatched` items, preserving base * collection order with unmatched extension items appended at the end. * * @example * // Single field matching with default precedence (base wins) * const configDevices = [ * { channel: "0.0.0160", diag: false, format: true } * ]; * const systemDevices = [ * { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: true }, * { channel: "0.0.0999", deviceName: "extra", type: "fba" } * ]; * const { extended, unmatched, all } = extendCollection(configDevices, { * with: systemDevices, * matching: 'channel' * }); * // extended: [ * // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true } * // ] * // unmatched: [ * // { channel: "0.0.0999", deviceName: "extra", type: "fba" } * // ] * // all: [ * // { channel: "0.0.0160", deviceName: "dasda", type: "eckd", diag: false, format: true }, * // { channel: "0.0.0999", deviceName: "extra", type: "fba" } * // ] * * @example * // Multiple field matching with extensionWins precedence * const configDevices = [ * { channel: "0.0.0160", state: "offline", diag: false } * ]; * const systemDevices = [ * { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }, * { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } * ]; * const { extended, unmatched, all } = extendCollection(configDevices, { * with: systemDevices, * matching: ['channel', 'state'], * precedence: "extensionWins" * }); * // extended: [ * // { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true } * // ] * // unmatched: [ * // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } * // ] * // all: [ * // { channel: "0.0.0160", state: "offline", deviceName: "dasda", type: "eckd", diag: true }, * // { channel: "0.0.0160", state: "active", deviceName: "dasdb", type: "fba" } * // ] */ function extendCollection( collection: T[] = [], options: ExtendCollectionOptions, ): ExtendCollectionResult { const { with: extension = [], matching = "id", precedence = "baseWins", mergeArrays } = options; // Normalize matching field(s) to array const keys = Array.isArray(matching) ? matching : [matching]; // Create a composite key from item values // For single field: returns the value directly (e.g., "0.0.0160") // For multiple fields: joins values with pipe separator (e.g., "0.0.0160|offline") const getKey = (item: T | U): string => keys.map((k) => String(item[k as keyof (T | U)])).join("|"); // Build a lookup map from extension items keyed by their matching fields const extensionLookup = new Map(extension.map((item) => [getKey(item), item])); const extended: (T & U)[] = []; const unmatched: U[] = []; const all: (T & U)[] = []; // Helper to merge two items with array merging support const mergeItems = (base: T, ext: U): T & U => { const merged = precedence === "baseWins" ? { ...ext, ...base } : { ...base, ...ext }; if (!mergeArrays) return merged as T & U; // Determine which properties should have their arrays merged const shouldMergeArray = (key: string): boolean => { if (mergeArrays === true) return true; if (Array.isArray(mergeArrays)) return mergeArrays.includes(key); return false; }; // Merge array properties for (const key of Object.keys(merged)) { if (!shouldMergeArray(key)) continue; const baseVal = base[key as keyof T]; const extVal = ext[key as keyof U]; if (isArray(baseVal) && isArray(extVal)) { // Concatenate arrays and remove duplicates const combined = [...baseVal, ...extVal]; merged[key as keyof typeof merged] = Array.from(new Set(combined)) as unknown as (T & U)[keyof (T & U)]; } } return merged as T & U; }; // Extend each item in the base collection for (const item of collection) { const key = getKey(item); const match = extensionLookup.get(key); const resolved = !match ? (item as T & U) : (extensionLookup.delete(key), mergeItems(item, match)); extended.push(resolved); all.push(resolved); } // Remaining items in lookup are unmatched — append to all to preserve // base collection order with unmatched extension items at the end for (const item of extensionLookup.values()) { unmatched.push(item); all.push(item as unknown as T & U); } return { all, extended, unmatched }; } /** Options for translateEntries utility */ type TranslateEntriesOptions = { /** Optional predicate to exclude entries by key. */ filter?: (key: string) => boolean; }; /** * Translates the values of a string record using `_()`, optionally filtering * entries by key. * * @example * // Translate all entries * const FORMAT_OPTIONS = { yes: N_("Yes"), no: N_("No") }; * translateEntries(FORMAT_OPTIONS) * * @example * // Translate only entries matching a condition * const STATUS_OPTIONS = { active: N_("Active"), offline: N_("Offline") }; * translateEntries(STATUS_OPTIONS, { filter: (status) => devices.some((d) => d.status === status) }) * * @param entries - A record whose values are translation keys. * @param options - Optional configuration. * @param options.filter - Optional predicate to exclude entries by key. * @returns A new record with the same keys and translated values. * */ const translateEntries = ( entries: Record, { filter }: TranslateEntriesOptions = {}, ): Record => Object.fromEntries( Object.entries(entries) .filter(([key]) => filter?.(key) ?? true) .map(([key, value]) => [key, _(value)]), ); /** * Returns the current date and time as an ISO 8601 string with colons and * dots replaced by hyphens, e.g. `2024-01-15T10-30-00-000Z`. * * @returns A string in the form `YYYY-MM-DDTHH-MM-SS-mmmZ`. * * @example * const filename = `agama-logs-${isoTimestamp()}.tar.gz`; */ const isoTimestamp = (): string => new Date().toISOString().replace(/[:.]/g, "-"); /** * Fetches a resource and triggers a browser file download. * * The download is performed via `fetch` so the caller can `await` completion, * enabling UI feedback (e.g. a loading indicator) that can be dismissed once * the browser Save dialog appears. * * @param url - URL of the resource to download. * @param filename - Suggested filename for the downloaded file. * * @throws {Error} If the server responds with a non-OK HTTP status. * * @example * await download("/api/private/download_logs", "agama-logs.tar.gz"); */ const download = async (url: string, filename: string): Promise => { const response = await fetch(url); if (!response.ok) throw new Error(`Request failed: ${response.status}`); const blob = await response.blob(); const anchor = document.createElement("a"); anchor.href = URL.createObjectURL(blob); anchor.download = filename; try { anchor.click(); } finally { URL.revokeObjectURL(anchor.href); } }; export { compact, hex, locationReload, setLocationSearch, localConnection, dateTimeFormat, clearDateTimeFormatCache, timezoneTime, mask, generateEncodedPath, maskSecrets, sortCollection, mergeSources, extendCollection, translateEntries, isoTimestamp, download, };