// Copyright 2022 The Parca Authors // Licensed 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 {useCallback, useEffect, useState} from 'react'; import {Icon} from '@iconify/react'; import {useQueryClient} from '@tanstack/react-query'; import cx from 'classnames'; import {useGrpcMetadata, useParcaContext} from '@parca/components'; import {Query} from '@parca/parser'; import {TEST_IDS, testId} from '@parca/test-utils'; import {millisToProtoTimestamp, sanitizeLabelValue} from '@parca/utilities'; import {QuerySelection} from '../ProfileSelector'; import {useUnifiedLabels} from '../contexts/UnifiedLabelsContext'; import {transformLabelName} from '../contexts/utils'; import Select, {type SelectItem} from './Select'; interface Props { queryBrowserRef: React.RefObject; searchExecutedTimestamp?: number; draftSelection: QuerySelection; setDraftMatchers: (selection: string) => void; draftParsedQuery?: Query | null; } interface QueryRow { labelName: string; operator: string; labelValue: string; labelValues: string[]; isLoading: boolean; } export const transformLabelsForSelect = (labelNames: string[]): SelectItem[] => { return labelNames.map(labelName => ({ key: labelName, element: { active: <>{transformLabelName(labelName)}, expanded: <>{transformLabelName(labelName)}, }, })); }; const operatorOptions = [ { key: '=', element: { active: <>Equals, expanded: ( <> Equals ), }, }, { key: '!=', element: { active: <>Not Equals, expanded: ( <> Not Equals ), }, }, { key: '=~', element: { active: <>Regex, expanded: ( <> Regex ), }, }, { key: '!~', element: { active: <>Not Regex, expanded: ( <> Not Regex ), }, }, ]; const SimpleMatchers = ({ queryBrowserRef, searchExecutedTimestamp, draftSelection, setDraftMatchers, draftParsedQuery, }: Props): JSX.Element => { const [queryRows, setQueryRows] = useState([ {labelName: '', operator: '=', labelValue: '', labelValues: [], isLoading: false}, ]); const reactQueryClient = useQueryClient(); const metadata = useGrpcMetadata(); const {queryServiceClient: parcaQueryClient} = useParcaContext(); const [showAll, setShowAll] = useState(false); const [isActivelyEditing, setIsActivelyEditing] = useState(false); const { labelNameMappingsForSimpleMatchers: labelNameOptions, isLabelNamesLoading: labelNamesLoading, refetchLabelNames, } = useUnifiedLabels(); // Reset editing mode when search is executed useEffect(() => { if (searchExecutedTimestamp !== undefined && searchExecutedTimestamp > 0) { setIsActivelyEditing(false); setShowAll(false); } }, [searchExecutedTimestamp]); const visibleRows = showAll || isActivelyEditing ? queryRows : queryRows.slice(0, 3); const hiddenRowsCount = queryRows.length - 3; const maxWidthInPixels = `max-w-[${queryBrowserRef.current?.offsetWidth.toString() as string}px]`; const currentMatchers = draftParsedQuery?.matchersString(); const profileType = draftParsedQuery?.profileType().toString(); const start = draftSelection.from; const end = draftSelection.to; const fetchLabelValues = useCallback( async (labelName: string): Promise => { if (labelName == null || labelName === '') { return []; } if (profileType == null || profileType === '') { return []; } try { const values = await reactQueryClient.fetchQuery( [labelName, profileType, start, end], async () => { const response = await parcaQueryClient.values( { labelName, match: [], profileType, ...(start !== undefined && end !== undefined ? { start: millisToProtoTimestamp(start), end: millisToProtoTimestamp(end), } : {}), }, {meta: metadata} ).response; console.log('Fetched label values:', response.labelValues); const sanitizedValues = sanitizeLabelValue(response.labelValues); return sanitizedValues; } ); return values; } catch (error) { console.error('Error fetching label values:', error); return []; } }, [parcaQueryClient, metadata, profileType, reactQueryClient, start, end] ); const updateMatchersString = useCallback( (rows: QueryRow[]) => { const matcherString = rows .filter(row => row.labelName.length > 0 && row.labelValue) .map(row => `${row.labelName}${row.operator}"${row.labelValue}"`) .join(','); setDraftMatchers(matcherString); }, [setDraftMatchers] ); // Helper to ensure selected label name is in the options (for page load before API returns) const getLabelNameOptionsWithSelected = useCallback( (selectedLabelName: string): typeof labelNameOptions => { if (selectedLabelName === '') return labelNameOptions; // Check if the selected label name exists in any group const existsInOptions = labelNameOptions.some(group => group.values.some(item => item.key === selectedLabelName) ); if (existsInOptions) return labelNameOptions; // Add it temporarily to a group with type '' (matching non-matching labels group) if (labelNameOptions.length === 0) { return [ { type: '', values: [ { key: selectedLabelName, element: {active: <>{selectedLabelName}, expanded: <>{selectedLabelName}}, }, ], }, ]; } // Find the group with type '' (non-matching labels) or create it const emptyTypeGroupIndex = labelNameOptions.findIndex(group => group.type === ''); if (emptyTypeGroupIndex !== -1) { // Add to existing '' type group const updatedOptions = [...labelNameOptions]; updatedOptions[emptyTypeGroupIndex] = { ...updatedOptions[emptyTypeGroupIndex], values: [ ...updatedOptions[emptyTypeGroupIndex].values, { key: selectedLabelName, element: {active: <>{selectedLabelName}, expanded: <>{selectedLabelName}}, }, ], }; return updatedOptions; } else { // Create new '' type group return [ ...labelNameOptions, { type: '', values: [ { key: selectedLabelName, element: {active: <>{selectedLabelName}, expanded: <>{selectedLabelName}}, }, ], }, ]; } }, [labelNameOptions] ); const fetchLabelValuesUnified = useCallback( async (labelName: string): Promise => { return await fetchLabelValues(labelName); }, [fetchLabelValues] ); useEffect(() => { if (currentMatchers === '' || currentMatchers === undefined) { const defaultRow = { labelName: '', operator: '=', labelValue: '', labelValues: [], isLoading: false, }; setQueryRows([defaultRow]); return; } let isCancelled = false; const fetchAndSetQueryRows = async (): Promise => { const newRows = await Promise.all( currentMatchers.split(',').map(async matcher => { const match = matcher.match(/^([^=!~]+)([=!~]{1,2})(.+)$/); if (match === null) return null; const [, labelName, operator, labelValue] = match; const trimmedLabelName = labelName.trim(); if (trimmedLabelName === '') return null; const labelValues = await fetchLabelValuesUnified(trimmedLabelName); const sanitizedLabelValue = labelValue.startsWith('"') && labelValue.endsWith('"') ? labelValue.slice(1, -1) : labelValue; return { labelName: trimmedLabelName, operator, labelValue: sanitizedLabelValue, labelValues, isLoading: false, }; }) ); if (!isCancelled) { const filteredRows = newRows.filter((row): row is QueryRow => row !== null); setQueryRows(filteredRows); } }; void fetchAndSetQueryRows(); return () => { isCancelled = true; }; }, [currentMatchers, fetchLabelValuesUnified]); const updateRow = useCallback( async (index: number, field: keyof QueryRow, value: string): Promise => { const updatedRows = [...queryRows]; const prevLabelName = updatedRows[index].labelName; updatedRows[index] = {...updatedRows[index], [field]: value}; if (field === 'labelName' && value !== prevLabelName) { updatedRows[index].labelValues = []; updatedRows[index].labelValue = ''; updatedRows[index].isLoading = true; setQueryRows([...updatedRows]); const labelValues = await fetchLabelValuesUnified(value); updatedRows[index].labelValues = labelValues; updatedRows[index].isLoading = false; } setQueryRows([...updatedRows]); updateMatchersString(updatedRows); }, [queryRows, fetchLabelValuesUnified, updateMatchersString] ); const handleUpdateRow = useCallback( (index: number, field: keyof QueryRow, value: string) => { // Only set actively editing if not already showing all // If showAll is true, user has explicitly expanded, so keep that state if (!showAll) { setIsActivelyEditing(true); } void updateRow(index, field, value); }, [updateRow, showAll] ); const addNewRow = useCallback((): void => { // Only set actively editing if not already showing all label names and values // If showAll is true, user has explicitly expanded, so keep that state if (!showAll) { setIsActivelyEditing(true); } const newRows = [ ...queryRows, {labelName: '', operator: '=', labelValue: '', labelValues: [], isLoading: false}, ]; setQueryRows(newRows); updateMatchersString(newRows); }, [queryRows, updateMatchersString, showAll]); const removeRow = useCallback( (index: number): void => { if (queryRows.length === 1) { // Reset the single row instead of removing it const resetRow = { labelName: '', operator: '=', labelValue: '', labelValues: [], isLoading: false, }; setQueryRows([resetRow]); updateMatchersString([resetRow]); } else { const updatedRows = queryRows.filter((_, i) => i !== index); setQueryRows(updatedRows); updateMatchersString(updatedRows); } }, [queryRows, updateMatchersString] ); const handleLabelValueClick = useCallback( (index: number) => { return async () => { const updatedRows = [...queryRows]; if (updatedRows[index].labelValues.length === 0 && updatedRows[index].labelName !== '') { updatedRows[index].isLoading = true; setQueryRows([...updatedRows]); try { const labelValues = await fetchLabelValuesUnified(updatedRows[index].labelName); updatedRows[index].labelValues = labelValues; } catch (error) { console.error('Error fetching label values:', error); } finally { updatedRows[index].isLoading = false; setQueryRows([...updatedRows]); } } else { console.log(`Label values already present or empty label name`); } }; }, [queryRows, fetchLabelValuesUnified] ); const isRowRegex = (row: QueryRow): boolean => row.operator === '=~' || row.operator === '!~'; const handleRefetchForLabelValues = useCallback( async (labelName: string): Promise => { await fetchLabelValuesUnified(labelName); }, [fetchLabelValuesUnified] ); return (
{visibleRows.map((row, index) => (
handleUpdateRow(index, 'operator', value)} selectedKey={row.operator} className="rounded-none ring-0 focus:ring-0 outline-none" {...testId(TEST_IDS.OPERATOR_SELECT)} />