From 47a6d611e63a4eb508075e41731d3fdddd9a09b3 Mon Sep 17 00:00:00 2001 From: Shweta Padubidri Date: Fri, 20 Mar 2026 14:01:01 -0400 Subject: [PATCH] Add support for dynamically querying multiple tenants in trace domain Signed-off-by: Shweta Padubidri --- web/src/components/Korrel8rPanel.tsx | 113 ++++++++++++++++++++------- web/src/korrel8r-client.ts | 23 ++++++ web/src/korrel8r/trace.ts | 6 +- web/src/redux-actions.ts | 5 ++ web/src/redux-reducers.ts | 4 + web/src/utils/traceStoreUtils.ts | 44 +++++++++++ 6 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 web/src/utils/traceStoreUtils.ts diff --git a/web/src/components/Korrel8rPanel.tsx b/web/src/components/Korrel8rPanel.tsx index a57a75b..48070f3 100644 --- a/web/src/components/Korrel8rPanel.tsx +++ b/web/src/components/Korrel8rPanel.tsx @@ -25,11 +25,20 @@ import { TFunction, useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useLocationQuery } from '../hooks/useLocationQuery'; import { usePluginAvailable } from '../hooks/usePluginAvailable'; -import { getGoalsGraph, getNeighborsGraph } from '../korrel8r-client'; +import { getGoalsGraph, getNeighborsGraph, replaceTraceStore } from '../korrel8r-client'; import * as api from '../korrel8r/client'; import * as korrel8r from '../korrel8r/types'; -import { defaultSearch, Result, Search, SearchType, setResult, setSearch } from '../redux-actions'; +import { + defaultSearch, + Result, + Search, + SearchType, + setResult, + setSearch, + setTraceContext, +} from '../redux-actions'; import { State } from '../redux-reducers'; +import { buildTraceStoreConfig, extractTraceContext, TraceContext } from '../utils/traceStoreUtils'; import * as time from '../time'; import { AdvancedSearchForm } from './AdvancedSearchForm'; import './korrel8rpanel.css'; @@ -43,6 +52,9 @@ export default function Korrel8rPanel() { const search: Search = useSelector((state: State) => state.plugins?.tp?.get('search')); const result: Result | null = useSelector((state: State) => state.plugins?.tp?.get('result')); + const storedTraceContext: TraceContext | null = useSelector((state: State) => + state.plugins?.tp?.get('traceContext'), + ); // Disable focus button if the panel is already focused on the current location, // or the current result is an error. @@ -92,6 +104,13 @@ export default function Korrel8rPanel() { // Skip the first fetch if we already have a stored result. const useStoredResult = React.useRef(result != null); + // Helper function to check if two TraceContexts are different + const traceContextsDiffer = (a: TraceContext | null, b: TraceContext | null): boolean => { + if (!a && !b) return false; + if (!a || !b) return true; + return a.namespace !== b.namespace || a.name !== b.name || a.tenant !== b.tenant; + }; + // Fetch a new result from the korrel8r service when the search changes. React.useEffect(() => { if (useStoredResult.current) { @@ -102,37 +121,77 @@ export default function Korrel8rPanel() { if (!queryStr) return; let cancelled = false; - const start: api.Start = { - queries: [queryStr], - constraint: constraint?.toAPI(), + + // Check if we need to update the trace store based on current URL + const currentTraceContext = extractTraceContext(); + const needsTraceStoreUpdate = traceContextsDiffer(currentTraceContext, storedTraceContext); + + // Helper to perform the korrel8r fetch + const performFetch = () => { + const start: api.Start = { + queries: [queryStr], + constraint: constraint?.toAPI(), + }; + + const fetch = + search.searchType === SearchType.Goal + ? getGoalsGraph({ start, goals: [search.goal] }) + : getNeighborsGraph({ start, depth: search.depth }); + fetch + .then((response: api.Graph) => { + if (cancelled) return; + dispatchResult( + Array.isArray(response?.nodes) && response.nodes.length > 0 + ? { graph: new korrel8r.Graph(response) } + : { title: t('Empty Result'), message: t('No correlated data found') }, + ); + }) + .catch((e: api.ApiError) => { + if (cancelled) return; + dispatchResult({ + title: e?.body?.error ? t('Search Error') : t('Search Failed'), + message: e?.body?.error || e.message || 'Unknown Error', + isError: true, + }); + }); + return fetch; }; - const fetch = - search.searchType === SearchType.Goal - ? getGoalsGraph({ start, goals: [search.goal] }) - : getNeighborsGraph({ start, depth: search.depth }); - fetch - .then((response: api.Graph) => { - if (cancelled) return; - dispatchResult( - Array.isArray(response?.nodes) && response.nodes.length > 0 - ? { graph: new korrel8r.Graph(response) } - : { title: t('Empty Result'), message: t('No correlated data found') }, - ); - }) - .catch((e: api.ApiError) => { - if (cancelled) return; - dispatchResult({ - title: e?.body?.error ? t('Search Error') : t('Search Failed'), - message: e?.body?.error || e.message || 'Unknown Error', - isError: true, + // If trace context changed, update the backend trace store first + if (needsTraceStoreUpdate && currentTraceContext) { + const storeConfig = buildTraceStoreConfig(currentTraceContext); + const timeoutMs = 3000; + + Promise.race([ + replaceTraceStore(storeConfig), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Trace store update timed out')), timeoutMs), + ), + ]) + .then(() => { + if (cancelled) return; + // eslint-disable-next-line no-console + console.log('Trace store updated for tenant:', currentTraceContext.tenant); + dispatch(setTraceContext(currentTraceContext)); + performFetch(); + }) + .catch((error) => { + if (cancelled) return; + // Log error but continue with fetch - panel will work with other domains + // eslint-disable-next-line no-console + console.error('Failed to update trace store:', error); + dispatch(setTraceContext(currentTraceContext)); + performFetch(); }); - }); + } else { + // No trace context change, just perform the fetch + performFetch(); + } + return () => { cancelled = true; - fetch.cancel(); }; - }, [search, constraint, dispatchResult, t]); + }, [search, constraint, dispatchResult, dispatch, storedTraceContext, t]); const advancedToggleID = 'query-toggle'; const advancedContentID = 'query-content'; diff --git a/web/src/korrel8r-client.ts b/web/src/korrel8r-client.ts index 196989d..9a6b2ac 100644 --- a/web/src/korrel8r-client.ts +++ b/web/src/korrel8r-client.ts @@ -35,3 +35,26 @@ export const getGoalsGraph = (goals: Goals) => { return korrel8rClient.default.postGraphsGoals(goals); }; + +export interface StoreConfig { + domain: string; + tempoStack?: string; + certificateAuthority?: string; + [key: string]: string | undefined; +} + +export const replaceTraceStore = async (storeConfig: StoreConfig): Promise => { + const response = await fetch(`${KORREL8R_ENDPOINT}/stores/trace`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken(), + }, + body: JSON.stringify(storeConfig), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to replace trace store: ${response.status} ${errorText}`); + } +}; diff --git a/web/src/korrel8r/trace.ts b/web/src/korrel8r/trace.ts index 1978889..cb91f95 100644 --- a/web/src/korrel8r/trace.ts +++ b/web/src/korrel8r/trace.ts @@ -6,8 +6,10 @@ import { Class, Constraint, Domain, Query, unixMilliseconds, URIRef } from './ty // Search for get selected spans // observe/traces/?namespace=&name=&tenant=&q= -// TODO hard-coded tempo location, need to make this configurable between console & korrel8r. -// Get from the console page environment (change from using URL as context?) +// NOTE: Default tempo location for generating links. +// When the troubleshooting panel opens, it reads the actual tenant from the URL +// and updates korrel8r's trace store configuration via PUT /stores/trace. +// These defaults are used when generating links back to the console. const [tempoNamespace, tempoName, tempoTenant] = ['openshift-tracing', 'platform', 'platform']; export class TraceDomain extends Domain { diff --git a/web/src/redux-actions.ts b/web/src/redux-actions.ts index f28fc03..0beccd9 100644 --- a/web/src/redux-actions.ts +++ b/web/src/redux-actions.ts @@ -1,12 +1,14 @@ import { action, ActionType as Action } from 'typesafe-actions'; import { Graph } from './korrel8r/types'; import { Duration, HOUR, Period } from './time'; +import { TraceContext } from './utils/traceStoreUtils'; export enum ActionType { CloseTroubleshootingPanel = 'closeTroubleshootingPanel', OpenTroubleshootingPanel = 'openTroubleshootingPanel', SetSearch = 'setSearch', SetResult = 'setResult', + SetTraceContext = 'setTraceContext', } export enum SearchType { @@ -43,12 +45,15 @@ export const closeTP = () => action(ActionType.CloseTroubleshootingPanel); export const openTP = () => action(ActionType.OpenTroubleshootingPanel); export const setSearch = (search: Search) => action(ActionType.SetSearch, search); export const setResult = (result: Result | null) => action(ActionType.SetResult, result); +export const setTraceContext = (traceContext: TraceContext | null) => + action(ActionType.SetTraceContext, traceContext); export const actions = { closeTP, openTP, setSearch, setResult, + setTraceContext, }; export type TPAction = Action; diff --git a/web/src/redux-reducers.ts b/web/src/redux-reducers.ts index 429b24a..baeb94c 100644 --- a/web/src/redux-reducers.ts +++ b/web/src/redux-reducers.ts @@ -20,6 +20,7 @@ const reducer = (state: TPState, action: TPAction): TPState => { isOpen: false, search: defaultSearch, result: null, + traceContext: null, }); } @@ -36,6 +37,9 @@ const reducer = (state: TPState, action: TPAction): TPState => { case ActionType.SetResult: return state.set('result', action.payload); + case ActionType.SetTraceContext: + return state.set('traceContext', action.payload); + default: break; } diff --git a/web/src/utils/traceStoreUtils.ts b/web/src/utils/traceStoreUtils.ts new file mode 100644 index 0000000..a97e696 --- /dev/null +++ b/web/src/utils/traceStoreUtils.ts @@ -0,0 +1,44 @@ +import { StoreConfig } from '../korrel8r-client'; + +export interface TraceContext { + namespace: string; + name: string; + tenant: string; +} + +/** + * Extracts trace context (namespace, name, tenant) from the current URL. + * These parameters are set when the user navigates to the traces console. + */ +export const extractTraceContext = (): TraceContext | null => { + const searchParams = new URLSearchParams(window.location.search); + + // Check if we're on a traces page with tenant information + const namespace = searchParams.get('namespace'); + const name = searchParams.get('name'); + const tenant = searchParams.get('tenant'); + + // If any of these are missing, return null (not on traces page or no tenant selected) + if (!namespace || !name || !tenant) { + return null; + } + + return { namespace, name, tenant }; +}; + +/** + * Builds a store configuration for the trace domain based on trace context. + */ +export const buildTraceStoreConfig = (context: TraceContext): StoreConfig => { + const { namespace, name, tenant } = context; + + // Build the tempoStack URL with the tenant path + // Format: https://tempo-{name}-gateway.{namespace}.svc.cluster.local:8080/api/traces/v1/{tenant}/tempo/api/search + const tempoStackURL = `https://tempo-${name}-gateway.${namespace}.svc.cluster.local:8080/api/traces/v1/${tenant}/tempo/api/search`; + + return { + domain: 'trace', + tempoStack: tempoStackURL, + certificateAuthority: '/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt', + }; +};