mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-24 12:20:41 +01:00
Compare commits
5 Commits
chore/impr
...
nv/api-sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c00dfa8aa | ||
|
|
370b278f28 | ||
|
|
f2229a1064 | ||
|
|
099832b26b | ||
|
|
057571cf6d |
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -280,3 +280,9 @@ go.mod @therealpandey
|
||||
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
## Storybook
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
/.claude/skills/signoz-page-story/ @H4ad
|
||||
/.claude/skills/storybook-visual-diff/ @H4ad
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,7 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
|
||||
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
|
||||
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
|
||||
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
|
||||
- **Stability**: Maturity marker (`handler.StabilityAlpha`, `handler.StabilityBeta`, `handler.StabilityStable`) emitted as the `x-stability` extension on every operation. Unset is emitted as `stable`.
|
||||
|
||||
The generic handler:
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
@@ -203,4 +204,21 @@ describe('CustomSelect Component', () => {
|
||||
// Check onChange was called
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
it('tells the consumer its search was cleared when the dropdown closes', async () => {
|
||||
// The component clears its own search text on close. A consumer running a
|
||||
// server-side search needs to hear that, or its results outlive the dropdown.
|
||||
const onSearch = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<CustomSelect options={mockOptions} onSearch={onSearch} />);
|
||||
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
await user.click(selectElement);
|
||||
await user.type(selectElement, 'opt');
|
||||
|
||||
expect(onSearch).toHaveBeenLastCalledWith('opt');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onSearch).toHaveBeenLastCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,6 +258,10 @@ $custom-border-color: #2c3044;
|
||||
overflow: hidden;
|
||||
|
||||
.group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
@@ -442,7 +446,7 @@ $custom-border-color: #2c3044;
|
||||
.group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
|
||||
@@ -10,6 +10,11 @@ export const MIN_LEGEND_ITEM_WIDTH = 110;
|
||||
/** Marker + row padding, on top of the estimated label width. */
|
||||
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
|
||||
|
||||
/** Must match `.gridList`'s column gap and `.scroller`'s padding-right, or the
|
||||
* reserved row count disagrees with the grid that gets laid out. */
|
||||
export const LEGEND_COLUMN_GAP = 8;
|
||||
export const LEGEND_SCROLLER_PADDING_RIGHT = 4;
|
||||
|
||||
/** Must match `.row`'s height and the grid's row gap, or the reserved
|
||||
* rectangle clips a row. */
|
||||
export const LEGEND_ROW_HEIGHT = 28;
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('calculateChartDimensions', () => {
|
||||
});
|
||||
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
// 1000px wide fits 5 of these per row, so 6 items need a second row.
|
||||
// 1000px wide fits 4 of these per row, so 6 items need a second row.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
@@ -123,6 +123,19 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
|
||||
// The item width alone suggests three fit on one row; the grid's per-item
|
||||
// padding and column gap leave room for two.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 412,
|
||||
containerHeight: 310,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: ['P99', 'P95', 'P50'],
|
||||
});
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(240);
|
||||
});
|
||||
|
||||
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_COLUMN_GAP,
|
||||
LEGEND_ITEM_EXTRA_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
LEGEND_SCROLLER_PADDING_RIGHT,
|
||||
MAX_LEGEND_WIDTH,
|
||||
} from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
@@ -143,9 +146,16 @@ export function calculateChartDimensions({
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
// Must resolve to the same track count as `.gridList`'s `auto-fill`; a more
|
||||
// generous one under-reserves rows and the grid's last row is clipped away.
|
||||
const gridWidth =
|
||||
containerWidth - LEGEND_PADDING * 2 - LEGEND_SCROLLER_PADDING_RIGHT;
|
||||
const legendItemsPerRow = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
Math.floor(
|
||||
(gridWidth + LEGEND_COLUMN_GAP) /
|
||||
(legendItemWidth + LEGEND_ITEM_EXTRA_WIDTH + LEGEND_COLUMN_GAP),
|
||||
),
|
||||
);
|
||||
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
@@ -163,8 +173,8 @@ export function calculateChartDimensions({
|
||||
);
|
||||
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
// the chart — the pie donut especially — collapses to a sliver. Dropping a
|
||||
// whole row beats clipping one.
|
||||
// the chart — the pie donut especially — collapses to a sliver. The dropped
|
||||
// row's items are clipped rather than removed, so they are scroll-only here.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
|
||||
@@ -35,6 +35,12 @@ function renderSelector(
|
||||
);
|
||||
}
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
}
|
||||
|
||||
/** Hovers an element and lets the tooltip's open delay elapse. */
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
@@ -112,17 +118,95 @@ describe('ValueSelector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dynamic variable', () => {
|
||||
function renderDynamic(
|
||||
complete: boolean,
|
||||
relatedValues: string[],
|
||||
): jest.Mock {
|
||||
const onSearch = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={OPTIONS}
|
||||
variableType="dynamic"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={{ value: [], allSelected: false }}
|
||||
onChange={jest.fn()}
|
||||
emptyFallback={{ value: [], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
dynamic={{
|
||||
values: OPTIONS,
|
||||
relatedValues,
|
||||
complete,
|
||||
onSearch,
|
||||
onSearchReset: jest.fn(),
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onSearch;
|
||||
}
|
||||
|
||||
it('splits related values out of the full list', async () => {
|
||||
renderDynamic(true, ['checkout-service-prod']);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: /Related Values/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: /All Values/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still opens its dropdown in single-select', async () => {
|
||||
// The shared single select spreads unknown props over its own handlers, so
|
||||
// passing it an `onDropdownVisibleChange` silently kills its open state.
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={OPTIONS}
|
||||
variableType="dynamic"
|
||||
multiSelect={false}
|
||||
showAllOption={false}
|
||||
selection={{ value: '', allSelected: false }}
|
||||
onChange={jest.fn()}
|
||||
emptyFallback={{ value: '', allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
dynamic={{
|
||||
values: OPTIONS,
|
||||
relatedValues: [],
|
||||
complete: false,
|
||||
onSearch: jest.fn(),
|
||||
onSearchReset: jest.fn(),
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(screen.getByText('cart-service-prod')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes typing to the API search when the list is truncated', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const onSearch = renderDynamic(false, []);
|
||||
|
||||
await openDropdown();
|
||||
await user.keyboard('pay');
|
||||
|
||||
expect(onSearch).toHaveBeenLastCalledWith('pay');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing', () => {
|
||||
function clearIcon(): Element | null {
|
||||
return document.querySelector('.ant-select-clear');
|
||||
}
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
}
|
||||
|
||||
it('offers no clear icon while the list is closed', () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
|
||||
@@ -114,4 +114,149 @@ describe('useFetchedVariableOptions', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.options).toStrictEqual(['prod']));
|
||||
});
|
||||
|
||||
it('keeps related values as their own section and as selectable options', async () => {
|
||||
mockGetFieldValues.mockResolvedValue({
|
||||
data: {
|
||||
normalizedValues: ['cart', 'payments'],
|
||||
relatedValues: ['checkout'],
|
||||
complete: true,
|
||||
},
|
||||
});
|
||||
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: { env: VariableFetchState.Loading },
|
||||
variableCycleIds: { env: 1 },
|
||||
});
|
||||
|
||||
const variable = dynamicVariable('env');
|
||||
const { result } = renderHook(
|
||||
() => useFetchedVariableOptions(variable, [variable], {}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.dynamic?.relatedValues).toStrictEqual(['checkout']),
|
||||
);
|
||||
expect(result.current.dynamic?.values).toStrictEqual(['cart', 'payments']);
|
||||
// A related value the unscoped list never returned is still selectable.
|
||||
expect(result.current.options).toStrictEqual([
|
||||
'cart',
|
||||
'payments',
|
||||
'checkout',
|
||||
]);
|
||||
});
|
||||
it('sends the search to the API when the list is incomplete', async () => {
|
||||
mockGetFieldValues.mockImplementation((_signal, _name, searchText) =>
|
||||
Promise.resolve({
|
||||
data: searchText
|
||||
? { normalizedValues: ['payments'], relatedValues: [], complete: false }
|
||||
: { normalizedValues: ['cart'], relatedValues: [], complete: false },
|
||||
}),
|
||||
);
|
||||
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: { env: VariableFetchState.Loading },
|
||||
variableCycleIds: { env: 1 },
|
||||
});
|
||||
|
||||
const variable = dynamicVariable('env');
|
||||
const { result } = renderHook(
|
||||
() => useFetchedVariableOptions(variable, [variable], {}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.dynamic?.values).toStrictEqual(['cart']),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.dynamic?.onSearch('pay');
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.dynamic?.values).toStrictEqual(['payments']),
|
||||
);
|
||||
expect(mockGetFieldValues).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
'service.name',
|
||||
'pay',
|
||||
1_000,
|
||||
2_000,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
// The search narrows the dropdown only — the selectable set is the full list,
|
||||
// so a pick made before searching is never reconciled away.
|
||||
expect(result.current.options).toStrictEqual(['cart']);
|
||||
|
||||
// Clearing falls straight back to the base fetch's options — synchronously, so
|
||||
// closing the dropdown cannot leave the last search's results on screen for a
|
||||
// debounce interval. They come from the cache of a separate query the search
|
||||
// never touched, so nothing is refetched.
|
||||
act(() => {
|
||||
result.current.dynamic?.onSearchReset();
|
||||
});
|
||||
|
||||
expect(result.current.dynamic?.values).toStrictEqual(['cart']);
|
||||
expect(mockGetFieldValues).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('marks a client error as not retryable', async () => {
|
||||
mockGetFieldValues.mockRejectedValue(
|
||||
Object.assign(new Error('bad request'), { response: { status: 400 } }),
|
||||
);
|
||||
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: { env: VariableFetchState.Loading },
|
||||
variableCycleIds: { env: 1 },
|
||||
});
|
||||
|
||||
const variable = dynamicVariable('env');
|
||||
const { result } = renderHook(
|
||||
() => useFetchedVariableOptions(variable, [variable], {}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isRetryable).toBe(false));
|
||||
});
|
||||
it('scopes the fetch by a sibling dynamic selection, skipping ALL', async () => {
|
||||
mockGetFieldValues.mockResolvedValue(fieldValues(['cart']));
|
||||
|
||||
useDashboardStore.setState({
|
||||
variableFetchStates: { env: VariableFetchState.Loading },
|
||||
variableCycleIds: { env: 1 },
|
||||
});
|
||||
|
||||
const env = dynamicVariable('env');
|
||||
const namespace: VariableFormModel = {
|
||||
...dynamicVariable('namespace'),
|
||||
dynamicAttribute: 'k8s.namespace.name',
|
||||
};
|
||||
const region: VariableFormModel = {
|
||||
...dynamicVariable('region'),
|
||||
dynamicAttribute: 'cloud.region',
|
||||
};
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useFetchedVariableOptions(env, [env, namespace, region], {
|
||||
namespace: { value: ['prod'], allSelected: false },
|
||||
// ALL means "no filter", so it contributes nothing to existingQuery —
|
||||
// which is why the backend returns no related values for it.
|
||||
region: { value: null, allSelected: true },
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockGetFieldValues).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
'service.name',
|
||||
undefined,
|
||||
1_000,
|
||||
2_000,
|
||||
"k8s.namespace.name = 'prod'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
|
||||
import type { DynamicVariableOptions } from '../../hooks/useFetchedVariableOptions';
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { dynamicVariableOptions } from '../../utils/dynamicVariableOptions';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
|
||||
import OverflowValuesTooltip from './OverflowValuesTooltip';
|
||||
@@ -24,6 +26,10 @@ interface ValueSelectorProps {
|
||||
/** Option-fetch error surfaced in the dropdown, with a retry action. */
|
||||
errorMessage?: string | null;
|
||||
onRetry?: () => void;
|
||||
/** Hides the retry action for an error that retrying cannot fix. */
|
||||
isRetryable?: boolean;
|
||||
/** DYNAMIC only: sectioned rendering and server-side search. */
|
||||
dynamic?: DynamicVariableOptions;
|
||||
}
|
||||
|
||||
function ValueSelector({
|
||||
@@ -38,10 +44,15 @@ function ValueSelector({
|
||||
testId,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
isRetryable = true,
|
||||
dynamic,
|
||||
}: ValueSelectorProps): JSX.Element {
|
||||
const optionData = useMemo<OptionData[]>(
|
||||
() => options.map((option) => ({ label: option, value: option })),
|
||||
[options],
|
||||
() =>
|
||||
dynamic
|
||||
? dynamicVariableOptions(dynamic.values, dynamic.relatedValues)
|
||||
: options.map((option) => ({ label: option, value: option })),
|
||||
[options, dynamic],
|
||||
);
|
||||
|
||||
// All-selected → the full option set so CustomMultiSelect engages its "all"
|
||||
@@ -119,6 +130,7 @@ function ValueSelector({
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showRetryButton={isRetryable}
|
||||
showSearch
|
||||
// Clearing belongs to the open list: on the closed control the icon would
|
||||
// appear on hover, in a row of variable pills, for an action whose result is
|
||||
@@ -136,6 +148,11 @@ function ValueSelector({
|
||||
)}
|
||||
// Offer ALL only once options load, else a concrete value reads as "all".
|
||||
enableAllSelection={showAllOption && options.length > 0}
|
||||
isDynamicVariable={!!dynamic}
|
||||
onSearch={dynamic?.onSearch}
|
||||
showIncompleteDataMessage={
|
||||
!!dynamic && !dynamic.complete && dynamic.values.length > 0
|
||||
}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
if (open) {
|
||||
setDraft(committedValues);
|
||||
@@ -144,6 +161,7 @@ function ValueSelector({
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
dynamic?.onSearchReset();
|
||||
commit(draft);
|
||||
}}
|
||||
onChange={(next): void => {
|
||||
@@ -180,8 +198,14 @@ function ValueSelector({
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showRetryButton={isRetryable}
|
||||
showSearch
|
||||
placeholder="Select value"
|
||||
isDynamicVariable={!!dynamic}
|
||||
onSearch={dynamic?.onSearch}
|
||||
showIncompleteDataMessage={
|
||||
!!dynamic && !dynamic.complete && dynamic.values.length > 0
|
||||
}
|
||||
onChange={(next): void => {
|
||||
void logEvent(
|
||||
DashboardDetailEvents.VariableValueSelected,
|
||||
|
||||
@@ -42,11 +42,8 @@ function VariableValueControl({
|
||||
onChange,
|
||||
onAutoSelect,
|
||||
}: VariableValueControlProps): JSX.Element {
|
||||
const { options, loading, errorMessage, onRetry } = useVariableOptions(
|
||||
variable,
|
||||
variables,
|
||||
selections,
|
||||
);
|
||||
const { options, loading, errorMessage, onRetry, isRetryable, dynamic } =
|
||||
useVariableOptions(variable, variables, selections);
|
||||
|
||||
useAutoSelect(variable, options, selection, onAutoSelect);
|
||||
|
||||
@@ -65,6 +62,8 @@ function VariableValueControl({
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
isRetryable={isRetryable}
|
||||
dynamic={dynamic}
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
emptyFallback={emptyFallback}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
interface UseDynamicVariableSearchProps {
|
||||
signal?: 'traces' | 'logs' | 'metrics';
|
||||
attribute?: string;
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
existingQuery?: string;
|
||||
/** Only a truncated list needs the API — a complete one is filtered in the dropdown. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DynamicVariableSearch {
|
||||
/** Results while a server search is in effect, else null — render the base options. */
|
||||
results: { values: string[]; relatedValues: string[] } | null;
|
||||
isSearching: boolean;
|
||||
onSearch: (text: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side value search for a DYNAMIC variable, deliberately kept off the fetch
|
||||
* engine's own query: a keystroke must not settle the variable's fetch cycle and
|
||||
* re-cascade its dependent variables and panels.
|
||||
*/
|
||||
export function useDynamicVariableSearch({
|
||||
signal,
|
||||
attribute,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
existingQuery,
|
||||
enabled,
|
||||
}: UseDynamicVariableSearchProps): DynamicVariableSearch {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
const isActive =
|
||||
enabled && !!attribute && !!searchText && !!debouncedSearchText;
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
[
|
||||
'dashboard-variable-dynamic-search',
|
||||
signal,
|
||||
attribute,
|
||||
debouncedSearchText,
|
||||
existingQuery,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
],
|
||||
({ signal: abortSignal }) =>
|
||||
getFieldValues(
|
||||
signal,
|
||||
attribute,
|
||||
debouncedSearchText,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
existingQuery,
|
||||
abortSignal,
|
||||
),
|
||||
{ enabled: isActive, refetchOnWindowFocus: false, keepPreviousData: true },
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => setSearchText(''), []);
|
||||
|
||||
// No results yet falls back to the base options rather than an empty dropdown:
|
||||
// the select filters them locally, so the list narrows while the API answers.
|
||||
const results = isActive ? data?.data : undefined;
|
||||
if (!results) {
|
||||
return {
|
||||
results: null,
|
||||
isSearching: isActive && isFetching,
|
||||
onSearch: setSearchText,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
results: {
|
||||
values: results.normalizedValues ?? [],
|
||||
relatedValues: results.relatedValues ?? [],
|
||||
},
|
||||
isSearching: isFetching,
|
||||
onSearch: setSearchText,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
|
||||
} from 'constants/queryCacheTime';
|
||||
import type { AppState } from 'store/reducers';
|
||||
import { isRetryableError } from 'utils/errorUtils';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
@@ -20,13 +21,29 @@ import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { buildExistingDynamicVariableQuery } from '../utils/dynamicFilter';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import { selectionToPayload } from '../utils/selectionUtils';
|
||||
import { useDynamicVariableSearch } from './useDynamicVariableSearch';
|
||||
import { useVariableFetchState } from './useVariableFetchState';
|
||||
|
||||
export interface DynamicVariableOptions {
|
||||
/** ALL VALUES section — narrowed to the API's matches while a search is active. */
|
||||
values: string[];
|
||||
/** RELATED VALUES section — scoped by the sibling dynamic variables' selections. */
|
||||
relatedValues: string[];
|
||||
/** false when the backend truncated the list, so searching has to hit the API. */
|
||||
complete: boolean;
|
||||
onSearch: (text: string) => void;
|
||||
onSearchReset: () => void;
|
||||
}
|
||||
|
||||
export interface VariableOptions {
|
||||
options: string[];
|
||||
loading: boolean;
|
||||
errorMessage: string | null;
|
||||
onRetry?: () => void;
|
||||
/** false for a client error, where retrying the same request cannot help. */
|
||||
isRetryable?: boolean;
|
||||
/** DYNAMIC only: what the dropdown renders, sectioned and search-aware. */
|
||||
dynamic?: DynamicVariableOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,10 +167,68 @@ export function useFetchedVariableOptions(
|
||||
return sortValuesByOrder(values, variable.sort).map(String);
|
||||
}, [dynamicResult.data, variable.sort]);
|
||||
|
||||
const dynamicRelatedOptions = useMemo(
|
||||
() =>
|
||||
sortValuesByOrder(
|
||||
dynamicResult.data?.data?.relatedValues ?? [],
|
||||
variable.sort,
|
||||
).map(String),
|
||||
[dynamicResult.data, variable.sort],
|
||||
);
|
||||
|
||||
// Related values are scoped by the sibling selections, so they can name values the
|
||||
// unscoped list never returned — the selectable set is the union of both sections.
|
||||
const dynamicSelectableOptions = useMemo(
|
||||
() => [...new Set([...dynamicOptions, ...dynamicRelatedOptions])],
|
||||
[dynamicOptions, dynamicRelatedOptions],
|
||||
);
|
||||
|
||||
const isDynamicListComplete = dynamicResult.data?.data?.complete ?? true;
|
||||
|
||||
const search = useDynamicVariableSearch({
|
||||
signal: signalForApi(variable.dynamicSignal),
|
||||
attribute: variable.dynamicAttribute,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
existingQuery: existingQuery || undefined,
|
||||
enabled: variable.type === 'DYNAMIC' && !isDynamicListComplete,
|
||||
});
|
||||
|
||||
// One stable object: the select rebuilds its whole option list whenever this
|
||||
// identity changes, so it must not be a literal rebuilt on every render.
|
||||
const dynamicDisplay = useMemo<DynamicVariableOptions>(() => {
|
||||
const display = search.results
|
||||
? {
|
||||
values: sortValuesByOrder(search.results.values, variable.sort).map(
|
||||
String,
|
||||
),
|
||||
relatedValues: sortValuesByOrder(
|
||||
search.results.relatedValues,
|
||||
variable.sort,
|
||||
).map(String),
|
||||
}
|
||||
: { values: dynamicOptions, relatedValues: dynamicRelatedOptions };
|
||||
|
||||
return {
|
||||
...display,
|
||||
complete: isDynamicListComplete,
|
||||
onSearch: search.onSearch,
|
||||
onSearchReset: search.reset,
|
||||
};
|
||||
}, [
|
||||
search.results,
|
||||
search.onSearch,
|
||||
search.reset,
|
||||
isDynamicListComplete,
|
||||
dynamicOptions,
|
||||
dynamicRelatedOptions,
|
||||
variable.sort,
|
||||
]);
|
||||
|
||||
// Flag a variable that settled with zero options so dependent panels fall through
|
||||
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
|
||||
const effectiveOptions =
|
||||
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
|
||||
variable.type === 'DYNAMIC' ? dynamicSelectableOptions : queryOptions;
|
||||
useEffect(() => {
|
||||
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
|
||||
return;
|
||||
@@ -175,14 +250,16 @@ export function useFetchedVariableOptions(
|
||||
|
||||
if (variable.type === 'DYNAMIC') {
|
||||
return {
|
||||
options: dynamicOptions,
|
||||
loading: dynamicResult.isFetching || isVariableWaiting,
|
||||
options: dynamicSelectableOptions,
|
||||
loading: dynamicResult.isFetching || isVariableWaiting || search.isSearching,
|
||||
errorMessage: dynamicResult.error
|
||||
? (dynamicResult.error as Error).message || null
|
||||
: null,
|
||||
onRetry: (): void => {
|
||||
void dynamicResult.refetch();
|
||||
},
|
||||
isRetryable: !dynamicResult.error || isRetryableError(dynamicResult.error),
|
||||
dynamic: dynamicDisplay,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -194,5 +271,6 @@ export function useFetchedVariableOptions(
|
||||
onRetry: (): void => {
|
||||
void queryResult.refetch();
|
||||
},
|
||||
isRetryable: !queryResult.error || isRetryableError(queryResult.error),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { OptionData } from 'components/NewSelect/types';
|
||||
|
||||
const toOptions = (values: string[]): OptionData[] =>
|
||||
values.map((value) => ({ label: value, value }));
|
||||
|
||||
/**
|
||||
* Dropdown options for a DYNAMIC variable: values scoped by the other dynamic
|
||||
* variables' selections get their own section above the unscoped list. Without
|
||||
* related values there is nothing to contrast, so the list stays flat.
|
||||
*/
|
||||
export function dynamicVariableOptions(
|
||||
values: string[],
|
||||
relatedValues: string[],
|
||||
): OptionData[] {
|
||||
if (relatedValues.length === 0) {
|
||||
return toOptions(values);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Related Values',
|
||||
value: 'relatedValues',
|
||||
options: toOptions(relatedValues),
|
||||
},
|
||||
{ label: 'All Values', value: 'allValues', options: toOptions(values) },
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package segmentanalytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
segment "github.com/segmentio/analytics-go/v3"
|
||||
@@ -18,11 +19,11 @@ func newSegmentLogger(settings factory.ScopedProviderSettings) segment.Logger {
|
||||
}
|
||||
|
||||
func (logger *logger) Logf(format string, args ...interface{}) {
|
||||
// the no lint directive is needed because the segmentlogger is not a slog.Logger
|
||||
logger.settings.Logger().InfoContext(context.TODO(), format, args...) //nolint:sloglint
|
||||
// the no lint directive is needed because the segment logger is not a slog.Logger
|
||||
logger.settings.Logger().InfoContext(context.TODO(), fmt.Sprintf(format, args...)) //nolint:sloglint
|
||||
}
|
||||
|
||||
func (logger *logger) Errorf(format string, args ...interface{}) {
|
||||
// the no lint directive is needed because the segment logger is not a slog.Logger
|
||||
logger.settings.Logger().ErrorContext(context.TODO(), format, args...) //nolint:sloglint
|
||||
logger.settings.Logger().ErrorContext(context.TODO(), fmt.Sprintf(format, args...)) //nolint:sloglint
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -173,6 +174,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -199,6 +201,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -226,6 +229,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -253,6 +257,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -281,6 +286,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -308,6 +314,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityBeta,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
|
||||
@@ -62,6 +62,10 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
opCtx.SetDescription(handler.openAPIDef.Description)
|
||||
opCtx.SetIsDeprecated(handler.openAPIDef.Deprecated)
|
||||
|
||||
if exposer, ok := opCtx.(openapi3.OperationExposer); ok {
|
||||
exposer.Operation().WithMapOfAnythingItem(openAPIStabilityKey, handler.openAPIDef.Stability.StringValue())
|
||||
}
|
||||
|
||||
// Add security schemes
|
||||
for _, securityScheme := range handler.openAPIDef.SecuritySchemes {
|
||||
opCtx.AddSecurity(securityScheme.Name, securityScheme.Scopes...)
|
||||
|
||||
52
pkg/http/handler/handler_test.go
Normal file
52
pkg/http/handler/handler_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
)
|
||||
|
||||
func TestServeOpenAPIStability(t *testing.T) {
|
||||
testCases := []struct {
|
||||
subtestName string
|
||||
stability Stability
|
||||
expectedExtensionValue any
|
||||
}{
|
||||
{
|
||||
subtestName: "beta is emitted as x-stability",
|
||||
stability: StabilityBeta,
|
||||
expectedExtensionValue: "beta",
|
||||
},
|
||||
{
|
||||
subtestName: "alpha is emitted as x-stability",
|
||||
stability: StabilityAlpha,
|
||||
expectedExtensionValue: "alpha",
|
||||
},
|
||||
{
|
||||
subtestName: "unset is emitted as stable",
|
||||
stability: Stability{},
|
||||
expectedExtensionValue: "stable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.subtestName, func(t *testing.T) {
|
||||
reflector := openapi3.NewReflector()
|
||||
opCtx, err := reflector.NewOperationContext(http.MethodGet, "/test")
|
||||
require.NoError(t, err)
|
||||
|
||||
New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{
|
||||
ID: "test",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Stability: testCase.stability,
|
||||
}).ServeOpenAPI(opCtx)
|
||||
require.NoError(t, reflector.AddOperation(opCtx))
|
||||
|
||||
operation := reflector.Spec.Paths.MapOfPathItemValues["/test"].MapOfOperationValues["get"]
|
||||
assert.Equal(t, testCase.expectedExtensionValue, operation.MapOfAnything["x-stability"])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,32 @@ package handler
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
openapigo "github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/rest/openapi"
|
||||
)
|
||||
|
||||
const openAPIStabilityKey string = "x-stability"
|
||||
|
||||
var (
|
||||
StabilityAlpha = Stability{valuer.NewString("alpha")}
|
||||
StabilityBeta = Stability{valuer.NewString("beta")}
|
||||
StabilityStable = Stability{valuer.NewString("stable")}
|
||||
)
|
||||
|
||||
// Stability is emitted as the x-stability extension on every operation; unset means stable.
|
||||
type Stability struct{ valuer.String }
|
||||
|
||||
func (stability Stability) StringValue() string {
|
||||
if stability.IsZero() {
|
||||
return StabilityStable.String.StringValue()
|
||||
}
|
||||
|
||||
return stability.String.StringValue()
|
||||
}
|
||||
|
||||
// OpenAPIExample is a named example for an OpenAPI operation.
|
||||
type OpenAPIExample struct {
|
||||
Name string
|
||||
@@ -32,6 +52,7 @@ type OpenAPIDef struct {
|
||||
SuccessStatusCode int
|
||||
ErrorStatusCodes []int
|
||||
Deprecated bool
|
||||
Stability Stability
|
||||
SecuritySchemes []OpenAPISecurityScheme
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user