UNPKG

@stolostron/multicluster-sdk

Version:

Provides extensions and APIs that dynamic plugins can use to leverage multicluster capabilities provided by Red Hat Advanced Cluster Management.

363 lines 18 kB
"use strict"; /* Copyright Contributors to the Open Cluster Management project */ Object.defineProperty(exports, "__esModule", { value: true }); exports.useFleetSearch = useFleetSearch; const react_1 = require("react"); const convertSearchItemToResource_1 = require("../internal/search/convertSearchItemToResource"); const search_client_1 = require("../internal/search/search-client"); const search_sdk_1 = require("../internal/search/search-sdk"); const useFleetSearchSubscription_1 = require("./useFleetSearchSubscription"); /** Extract the raw item list from a search query response payload. */ function extractSearchItems(data) { const items = data?.searchResult?.[0]?.items; return items ? items.filter(Boolean) : []; } // ── Helpers ──────────────────────────────────────────────────────────────── /** Determine the event-patching mode from the current search input. */ function getSearchMode(input) { const hasPagination = input != null && ((input.limit != null && input.limit > 0) || (input.offset != null && input.offset > 0)); if (!hasPagination) return 'unbounded'; return input.orderBy ? 'paginated-ordered' : 'paginated-unordered'; } /** * Signed comparison that accounts for sort direction. * Negative → `a` comes before `b` in the sorted order, positive → after. */ function sortCmp(a, b, descending) { return descending ? b.localeCompare(a) : a.localeCompare(b); } /** * Insert `newItem` into `items` at the position dictated by `orderBy`, or * append it at the end when `orderBy` is absent. */ function insertSorted(items, newItem, orderBy) { if (!orderBy) return [...items, newItem]; const [field, dir] = orderBy.trim().split(/\s+/); const descending = dir?.toLowerCase() === 'desc'; const newVal = String(newItem[field] ?? ''); const insertIdx = items.findIndex((item) => sortCmp(newVal, String(item[field] ?? ''), descending) < 0); if (insertIdx === -1) return [...items, newItem]; return [...items.slice(0, insertIdx), newItem, ...items.slice(insertIdx)]; } /** * Decide whether a subscription event requires a full refetch rather than a * local patch, given the current page contents and search mode. * * Unbounded queries — never refetch; all events patch in place. * * Paginated-unordered — ADD and DELETE always refetch because item positions * are non-deterministic without a sort order. UPDATE patches in place. * * Paginated-ordered: * ADD * • new item sorts ≤ first item on the page → page has shifted → refetch * • new item sorts strictly between first and last → local insert + drop last * • new item sorts equal to last item → server tie-breaking unknown → refetch * • new item sorts > last item → not on this page → ignore (no refetch) * DELETE * • deleted item is on this page → gap opens that needs backfilling → refetch * • deleted item is not on this page but sorts ≤ last item (earlier page) → shift → refetch * • deleted item sorts > last item → on a later page → ignore (no refetch) * • oldData unavailable → position unknown → refetch to be safe * UPDATE — always patches in place; never triggers a refetch. */ function wouldTriggerRefetch(event, current, mode, input) { if (mode === 'unbounded') return false; if (mode === 'paginated-unordered') { return event.operation === 'INSERT' || event.operation === 'DELETE'; } // ── paginated-ordered ──────────────────────────────────────────────────── const [field, dir] = (input?.orderBy ?? '').trim().split(/\s+/); const descending = dir?.toLowerCase() === 'desc'; if (event.operation === 'INSERT') { if (!event.newData) return false; if (current.length === 0) return true; // empty page — cannot determine position → refetch const cluster = event.uid.split('/')[0]; const newVal = String({ ...event.newData, cluster, _uid: event.uid }[field] ?? ''); const firstVal = String(current[0][field] ?? ''); const lastVal = String(current[current.length - 1][field] ?? ''); const cmpFirst = sortCmp(newVal, firstVal, descending); const cmpLast = sortCmp(newVal, lastVal, descending); if (cmpFirst <= 0) return true; // ≤ first → page has shifted → refetch if (cmpLast === 0) return true; // tied with last → server tie-breaking unknown → refetch if (cmpLast > 0) return false; // > last → not on this page → ignore return false; // strictly between → local insert } if (event.operation === 'DELETE') { if (current.length === 0) return false; if (current.some((item) => item._uid === event.uid)) return true; // on this page → gap → refetch // Not on this page. Check position using oldData. const oldRaw = event.oldData; if (oldRaw) { const cluster = event.uid.split('/')[0]; const oldVal = String({ ...oldRaw, cluster, _uid: event.uid }[field] ?? ''); const lastVal = String(current[current.length - 1][field] ?? ''); // ≤ last → was on an earlier page; its removal shifts our window → refetch return sortCmp(oldVal, lastVal, descending) <= 0; } return true; // no oldData — position unknown → refetch to be safe } return false; // UPDATE never triggers a refetch } /** * Apply a subscription event directly to the current page and return the updated array. * Only called when `wouldTriggerRefetch` returned `false` for this event. */ function applyEvent(current, event, mode, input) { switch (event.operation) { case 'INSERT': { if (!event.newData) return current; const cluster = event.uid.split('/')[0]; const patchedItem = { ...event.newData, cluster, _uid: event.uid }; if (current.some((item) => item._uid === patchedItem._uid)) return current; // dedup if (mode === 'unbounded') { return insertSorted(current, patchedItem, input?.orderBy); } // paginated-ordered: item sorts strictly between first and last const inserted = insertSorted(current, patchedItem, input?.orderBy); const limit = input?.limit; if (limit != null && limit > 0 && inserted.length > limit) return inserted.slice(0, limit); return inserted; } case 'UPDATE': { if (!event.newData) return current; const cluster = event.uid.split('/')[0]; const patchedItem = { ...event.newData, cluster, _uid: event.uid }; // Update in place. No re-sort: K8s names are immutable, and re-sorting a paginated // result would cause items to jump across page boundaries unpredictably. return current.map((item) => (item._uid === patchedItem._uid ? patchedItem : item)); } case 'DELETE': { return current.filter((item) => item._uid !== event.uid); } default: return current; } } /** * A React hook that provides fleet-wide search functionality using the ACM search API, * with optional real-time updates via a GraphQL WebSocket subscription. * * When `subscriptionEnabled` is `false` (the default), the hook issues a one-shot * GraphQL query and returns the results. When `subscriptionEnabled` is `true`, the * hook additionally opens a WebSocket subscription and patches the locally-held * results as INSERT, UPDATE, and DELETE events arrive — keeping the data always * up to date without polling. * * Pagination is supported by setting `limit` and `offset` on the `SearchInput` * object. The caller is responsible for constructing those values. * * @param input - The search input object (filters, keywords, limit, offset, etc.). * Pass `undefined` to skip the query entirely. * @param subscriptionEnabled - When `true`, a WebSocket subscription is opened * and the local result set is kept current via incremental event patches. * Defaults to `false`. * * @returns A tuple of: * - `data` — The current search results mapped through * {@link convertSearchItemToResource}, or `undefined` before the first * response arrives. * - `loaded` — `true` once the initial query has completed (regardless of * whether the subscription is active). * - `error` — Any query or subscription error, or `undefined` on success. * - `refetch` — A stable callback that re-executes the base query and resets * the local state to the fresh result. * * @example * ```typescript * // Basic query — no real-time updates * const [resources, loaded, error, refetch] = useFleetSearch({ * filters: [ * { property: 'kind', values: ['Pod'] }, * { property: 'namespace', values: ['default'] }, * ], * limit: 100, * }) * * // With real-time subscription — results update automatically * const [resources, loaded, error, refetch] = useFleetSearch( * { * filters: [ * { property: 'kind', values: ['Pod'] }, * { property: 'namespace', values: ['default'] }, * ], * }, * true, * ) * * // With subscription enabled and pagination/ordering — page 2 of 20 results sorted by name * const PAGE_SIZE = 20 * const [page, setPage] = useState(1) * const [resources, loaded, error, refetch] = useFleetSearch( * { * filters: [ * { property: 'kind', values: ['Pod'] }, * { property: 'namespace', values: ['default'] }, * ], * limit: PAGE_SIZE, * offset: (page - 1) * PAGE_SIZE, * orderBy: 'name asc', * }, * true, * ) * ``` */ function useFleetSearch(input, subscriptionEnabled) { // ── Base query ───────────────────────────────────────────────────────────── const { data: queryResult, loading, error: queryError, refetch, } = (0, search_sdk_1.useSearchResultItemsQuery)({ client: search_client_1.searchClient, skip: input === undefined, variables: { input: [input] }, }); // Derive the raw item list from the query response — conversion to K8s // resources happens once at return time via useMemo. const queryData = (0, react_1.useMemo)(() => { const items = queryResult?.searchResult?.[0]?.items; if (!items) return undefined; return items.filter(Boolean); }, [queryResult]); // ── Local state (patched by subscription events) ─────────────────────────── const [localData, setLocalData] = (0, react_1.useState)(queryData); // Ref that mirrors localData so event-handler effects can read the current // page contents without listing localData as a dependency (which would cause // stale-event replay on every data change). const localDataRef = (0, react_1.useRef)([]); // Tracks whether a refetch is currently in flight. const isRefetchingRef = (0, react_1.useRef)(false); // Events that arrive while a refetch is in flight are queued here and // applied (or trigger another refetch) once the fresh data lands. const eventQueueRef = (0, react_1.useRef)([]); // Keep a ref to the latest input so effects can always read current // orderBy / limit without triggering re-runs when input changes. const inputRef = (0, react_1.useRef)(input); (0, react_1.useEffect)(() => { inputRef.current = input; }); // Reconcile events that queued up while a subscription-triggered refetch was // in flight against the freshly fetched data. If a queued event itself // demands another refetch, chain into one instead of settling on stale data. // // Completion is driven by the Promise returned by `refetch()` rather than by // observing `queryData` change: Apollo may keep the exact same `data` // reference (and skip a re-render) when a refetch's result is deeply equal // to what's already cached — e.g. an item is inserted locally (without // updating the base query) and then deleted, so the refetch result matches // the still-cached pre-insert data byte-for-byte. Relying on reference // identity would leave `isRefetchingRef` stuck `true` forever and local data // permanently stale. const settleRefetch = (0, react_1.useCallback)((freshData) => { const queue = eventQueueRef.current; eventQueueRef.current = []; const currentInput = inputRef.current; const mode = getSearchMode(currentInput); // If any queued event would require yet another refetch, chain into it // and discard the queue — the next fresh result will settle the state. const refetchEvent = queue.find((e) => wouldTriggerRefetch(e, freshData, mode, currentInput)); if (refetchEvent) { localDataRef.current = freshData; setLocalData(freshData); refetch() .then((result) => settleRefetch(extractSearchItems(result.data))) .catch(() => { isRefetchingRef.current = false; }); return; } // No further refetch needed — walk the queue and apply any events that // are not already reflected in the fresh data. const finalData = queue.reduce((acc, e) => applyEvent(acc, e, mode, currentInput), freshData); localDataRef.current = finalData; isRefetchingRef.current = false; setLocalData(finalData); }, [refetch]); // Kick off a refetch and settle local state once the network response // resolves, via the returned Promise (see `settleRefetch` for why we can't // rely on `queryData` reference identity here). const startRefetch = (0, react_1.useCallback)(() => { isRefetchingRef.current = true; eventQueueRef.current = []; refetch() .then((result) => settleRefetch(extractSearchItems(result.data))) .catch(() => { isRefetchingRef.current = false; }); }, [refetch, settleRefetch]); // When fresh query data arrives from the initial load (or Apollo pushes new // data outside of our own refetch flow), reset local state. Skipped while a // subscription-triggered refetch is in flight — that path is settled by // `settleRefetch` above once the Promise resolves. (0, react_1.useEffect)(() => { if (isRefetchingRef.current) return; localDataRef.current = queryData ?? []; setLocalData(queryData); }, [queryData]); // When subscriptionEnabled is turned off, reset to the base query result // and clear any in-flight refetch state. (0, react_1.useEffect)(() => { if (!subscriptionEnabled) { isRefetchingRef.current = false; eventQueueRef.current = []; localDataRef.current = queryData ?? []; setLocalData(queryData); } // We intentionally only react to the subscriptionEnabled flag here. // eslint-disable-next-line react-hooks/exhaustive-deps }, [subscriptionEnabled]); // ── Subscription layer ───────────────────────────────────────────────────── // Pass undefined as input when subscription is disabled so the inner hook // skips the WebSocket connection entirely. const [latestEvent, , subscriptionError] = (0, useFleetSearchSubscription_1.useFleetSearchSubscription)(subscriptionEnabled ? input : undefined); // Route each incoming subscription event based on the current search mode. (0, react_1.useEffect)(() => { if (!latestEvent) return; // While a refetch is in flight, queue events for later processing. if (isRefetchingRef.current) { eventQueueRef.current = [...eventQueueRef.current, latestEvent]; return; } const currentInput = inputRef.current; const mode = getSearchMode(currentInput); const current = localDataRef.current; if (wouldTriggerRefetch(latestEvent, current, mode, currentInput)) { startRefetch(); return; } const next = applyEvent(current, latestEvent, mode, currentInput); localDataRef.current = next; setLocalData(next); }, [latestEvent, startRefetch]); // ── Stable refetch callback ──────────────────────────────────────────────── const triggerRefetch = (0, react_1.useCallback)(() => { if (isRefetchingRef.current) return; startRefetch(); }, [startRefetch]); // ── Return ───────────────────────────────────────────────────────────────── // Convert the raw SearchItems to K8s resources once, only when localData changes. const data = (0, react_1.useMemo)(() => { if (!localData) return undefined; return localData.map((item) => (0, convertSearchItemToResource_1.convertSearchItemToResource)(item)); }, [localData]); const error = queryError ?? subscriptionError; return [data, !loading, error, triggerRefetch]; } //# sourceMappingURL=useFleetSearch.js.map