mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-07 12:00:42 +01:00
Compare commits
2 Commits
refactor/p
...
fixes/dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a7a6724c | ||
|
|
b0d068a924 |
@@ -0,0 +1,13 @@
|
||||
import { useAutoRefreshSelection } from './useAutoRefreshSelection';
|
||||
import { useAutoRefreshTick } from './useAutoRefreshTick';
|
||||
|
||||
/** Auto-refresh timer for views that hide the time selector that normally owns it. */
|
||||
function AutoRefreshTicker(): null {
|
||||
const { isEnabled, intervalMs } = useAutoRefreshSelection();
|
||||
|
||||
useAutoRefreshTick(isEnabled, intervalMs);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AutoRefreshTicker;
|
||||
@@ -0,0 +1,116 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import AutoRefresh from '../index';
|
||||
|
||||
const mockStore = configureStore<Partial<AppState>>([]);
|
||||
|
||||
const PATHNAME = '/dashboard/test-id';
|
||||
const randomTime = 1700000000000000000;
|
||||
|
||||
function createGlobalTimeState(
|
||||
overrides: Partial<GlobalReducer> = {},
|
||||
): GlobalReducer {
|
||||
return {
|
||||
minTime: randomTime,
|
||||
maxTime: randomTime,
|
||||
loading: false,
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '5s',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAutoRefresh(
|
||||
globalTime: GlobalReducer,
|
||||
props: { disabled?: boolean } = {},
|
||||
): MockStoreEnhanced<Partial<AppState>> {
|
||||
const store = mockStore({ globalTime });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[PATHNAME]}>
|
||||
<Provider store={store}>
|
||||
<AutoRefresh {...props} />
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function tickCount(store: MockStoreEnhanced<Partial<AppState>>): number {
|
||||
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL)
|
||||
.length;
|
||||
}
|
||||
|
||||
describe('AutoRefresh', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the trigger and ticks on the persisted interval', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderAutoRefresh(createGlobalTimeState());
|
||||
|
||||
expect(screen.getByTitle('Set auto refresh')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(15_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(3);
|
||||
});
|
||||
|
||||
it('does not tick when auto refresh was never enabled for the route', () => {
|
||||
const store = renderAutoRefresh(createGlobalTimeState());
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not tick while the disabled prop is set', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderAutoRefresh(createGlobalTimeState(), { disabled: true });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(0);
|
||||
});
|
||||
|
||||
it('renders nothing on a custom time range', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderAutoRefresh(
|
||||
createGlobalTimeState({ selectedTime: 'custom' }),
|
||||
);
|
||||
|
||||
expect(screen.queryByTitle('Set auto refresh')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import AutoRefresh from '../index';
|
||||
import AutoRefreshTicker from '../AutoRefreshTicker';
|
||||
|
||||
const mockStore = configureStore<Partial<AppState>>([]);
|
||||
|
||||
const PATHNAME = '/dashboard/test-id';
|
||||
const randomTime = 1700000000000000000;
|
||||
|
||||
function createGlobalTimeState(
|
||||
overrides: Partial<GlobalReducer> = {},
|
||||
): GlobalReducer {
|
||||
return {
|
||||
minTime: randomTime,
|
||||
maxTime: randomTime,
|
||||
loading: false,
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '5s',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTicker(
|
||||
globalTime: GlobalReducer,
|
||||
): MockStoreEnhanced<Partial<AppState>> {
|
||||
const store = mockStore({ globalTime });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[PATHNAME]}>
|
||||
<Provider store={store}>
|
||||
<AutoRefreshTicker />
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function timeIntervalActions(
|
||||
store: MockStoreEnhanced<Partial<AppState>>,
|
||||
): unknown[] {
|
||||
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL);
|
||||
}
|
||||
|
||||
describe('AutoRefreshTicker', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('advances the global time window on the interval persisted for the route', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderTicker(createGlobalTimeState());
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not tick when the route has no persisted interval', () => {
|
||||
const store = renderTicker(createGlobalTimeState());
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not tick while auto refresh is globally disabled', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderTicker(
|
||||
createGlobalTimeState({ isAutoRefreshDisabled: true }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not tick on a custom time range', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderTicker(createGlobalTimeState({ selectedTime: 'custom' }));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors DashboardContainer's swap: exactly one of the two must be ticking.
|
||||
describe('AutoRefresh full screen handover', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps a single timer running across entering and leaving full screen', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = mockStore({ globalTime: createGlobalTimeState() });
|
||||
|
||||
function Harness({ active }: { active: boolean }): JSX.Element {
|
||||
return active ? <AutoRefreshTicker /> : <AutoRefresh />;
|
||||
}
|
||||
|
||||
const renderHarness = (active: boolean): JSX.Element => (
|
||||
<MemoryRouter initialEntries={[PATHNAME]}>
|
||||
<Provider store={store}>
|
||||
<Harness active={active} />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const { rerender } = render(renderHarness(false));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(timeIntervalActions(store)).toHaveLength(2);
|
||||
|
||||
rerender(renderHarness(true));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(timeIntervalActions(store)).toHaveLength(4);
|
||||
|
||||
rerender(renderHarness(false));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(timeIntervalActions(store)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useInterval } from 'react-use';
|
||||
import { Check, ChevronDown } from '@signozhq/icons';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
@@ -11,21 +10,18 @@ import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
import _omit from 'lodash-es/omit';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import {
|
||||
UPDATE_AUTO_REFRESH_INTERVAL,
|
||||
UPDATE_TIME_INTERVAL,
|
||||
} from 'types/actions/globalTime';
|
||||
import { UPDATE_AUTO_REFRESH_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { refreshIntervalOptions } from './constants';
|
||||
import { ButtonContainer } from './styles';
|
||||
import { useAutoRefreshTick } from './useAutoRefreshTick';
|
||||
|
||||
import './AutoRefreshV2.styles.scss';
|
||||
|
||||
@@ -93,30 +89,10 @@ function AutoRefresh({
|
||||
[selectedOption],
|
||||
);
|
||||
|
||||
useInterval(() => {
|
||||
const selectedValue = getOption?.value;
|
||||
|
||||
if (isDisabled || !isAutoRefreshEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOption !== 'off' && selectedValue) {
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: {
|
||||
maxTime,
|
||||
minTime,
|
||||
selectedTime: globalTime.selectedTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, getOption?.value || 0);
|
||||
useAutoRefreshTick(
|
||||
!isDisabled && isAutoRefreshEnabled && selectedOption !== 'off',
|
||||
getOption?.value || 0,
|
||||
);
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(selectedValue: string) => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
|
||||
import { refreshIntervalOptions } from './constants';
|
||||
|
||||
export interface AutoRefreshSelection {
|
||||
isEnabled: boolean;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry for the current route means auto-refresh is on, its absence means off.
|
||||
* Read on every render because localStorage isn't reactive.
|
||||
*/
|
||||
export function useAutoRefreshSelection(): AutoRefreshSelection {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const selectedOption = JSON.parse(get(DASHBOARD_TIME_IN_DURATION) || '{}')[
|
||||
pathname
|
||||
];
|
||||
|
||||
return {
|
||||
isEnabled: Boolean(selectedOption),
|
||||
intervalMs:
|
||||
refreshIntervalOptions.find((option) => option.key === selectedOption)
|
||||
?.value || 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useInterval } from 'react-use';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
/**
|
||||
* Advances the global time window on the auto-refresh interval. The global
|
||||
* "auto refresh disabled" flag and a custom range override the caller's `enabled`.
|
||||
*/
|
||||
export function useAutoRefreshTick(enabled: boolean, intervalMs: number): void {
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
|
||||
const isTicking =
|
||||
enabled &&
|
||||
intervalMs > 0 &&
|
||||
!globalTime.isAutoRefreshDisabled &&
|
||||
globalTime.selectedTime !== 'custom';
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: {
|
||||
maxTime,
|
||||
minTime,
|
||||
selectedTime: globalTime.selectedTime,
|
||||
},
|
||||
});
|
||||
},
|
||||
isTicking ? intervalMs : null,
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import {
|
||||
panelTypeDataSourceFormValuesMap,
|
||||
type PartialPanelTypes,
|
||||
} from 'lib/query/panelQuery';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
/**
|
||||
* The map is composed from a few shape rules rather than spelled out per panel type
|
||||
* and data source. These specs pin the rules themselves — each one fails only when a
|
||||
* rule changes, which is the moment to stop and decide, rather than whenever any
|
||||
* field moves.
|
||||
*
|
||||
* The composition it replaced was checked cell by cell against the previous literal
|
||||
* table, which is in git history at `main:frontend/src/lib/query/panelQuery.ts`.
|
||||
*/
|
||||
function fieldsFor(
|
||||
panelType: keyof PartialPanelTypes,
|
||||
dataSource: DataSource,
|
||||
): string[] {
|
||||
return panelTypeDataSourceFormValuesMap[panelType][dataSource].builder
|
||||
.queryData;
|
||||
}
|
||||
|
||||
/** Fields present in `to` but not in `from`. */
|
||||
function added(from: string[], to: string[]): string[] {
|
||||
return to.filter((field) => !from.includes(field)).sort();
|
||||
}
|
||||
|
||||
/** Panel types built on the aggregating field list. */
|
||||
const AGGREGATING_TYPES: (keyof PartialPanelTypes)[] = [
|
||||
PANEL_TYPES.BAR,
|
||||
PANEL_TYPES.HISTOGRAM,
|
||||
PANEL_TYPES.TABLE,
|
||||
PANEL_TYPES.PIE,
|
||||
];
|
||||
|
||||
/** Panel types that reduce each series to one cell or slice. */
|
||||
const SCALAR_TYPES: (keyof PartialPanelTypes)[] = [
|
||||
PANEL_TYPES.TABLE,
|
||||
PANEL_TYPES.PIE,
|
||||
];
|
||||
|
||||
describe('panelTypeDataSourceFormValuesMap', () => {
|
||||
const seriesLogs = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
|
||||
const seriesMetrics = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
|
||||
|
||||
it('shares one builder surface between logs and traces', () => {
|
||||
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
|
||||
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(
|
||||
sources[DataSource.TRACES].builder.queryData,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The provider pushes onto the list it reads from this map, so two cells backed by
|
||||
// one instance would leak fields into each other.
|
||||
it('gives every cell its own array instance', () => {
|
||||
const arrays = Object.values(panelTypeDataSourceFormValuesMap).flatMap(
|
||||
(sources) =>
|
||||
Object.values(sources).map((source) => source.builder.queryData),
|
||||
);
|
||||
|
||||
expect(new Set(arrays).size).toBe(arrays.length);
|
||||
});
|
||||
|
||||
// One consequence of composing: the aggregating types share a single field list, so
|
||||
// an edit meant for charts reaches table and pie too.
|
||||
it.each(AGGREGATING_TYPES)(
|
||||
'gives %s the same non-metrics fields as a time series',
|
||||
(panelType) => {
|
||||
expect(fieldsFor(panelType, DataSource.LOGS)).toStrictEqual(seriesLogs);
|
||||
},
|
||||
);
|
||||
|
||||
it('adds both metrics aggregation steps for metrics', () => {
|
||||
expect(added(seriesLogs, seriesMetrics)).toStrictEqual([
|
||||
'spaceAggregation',
|
||||
'timeAggregation',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(SCALAR_TYPES)('offers reduceTo to %s on metrics only', (panelType) => {
|
||||
expect(
|
||||
added(seriesMetrics, fieldsFor(panelType, DataSource.METRICS)),
|
||||
).toStrictEqual(['reduceTo']);
|
||||
expect(fieldsFor(panelType, DataSource.LOGS)).not.toContain('reduceTo');
|
||||
});
|
||||
|
||||
it('drops grouping, paging and ordering for a single value', () => {
|
||||
const value = fieldsFor(PANEL_TYPES.VALUE, DataSource.LOGS);
|
||||
|
||||
expect(added(value, seriesLogs)).toStrictEqual([
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
]);
|
||||
expect(value).toContain('reduceTo');
|
||||
});
|
||||
|
||||
it('offers no aggregation fields to raw rows', () => {
|
||||
const rows = fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS);
|
||||
|
||||
expect(rows).not.toContain('aggregateAttribute');
|
||||
expect(rows).not.toContain('aggregateOperator');
|
||||
expect(rows).not.toContain('groupBy');
|
||||
expect(rows).not.toContain('having');
|
||||
expect(rows).not.toContain('stepInterval');
|
||||
});
|
||||
|
||||
it('drops paging and ordering for metrics rows', () => {
|
||||
expect(
|
||||
added(
|
||||
fieldsFor(PANEL_TYPES.LIST, DataSource.METRICS),
|
||||
fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS),
|
||||
),
|
||||
).toStrictEqual(['functions', 'limit', 'orderBy']);
|
||||
});
|
||||
});
|
||||
@@ -101,99 +101,441 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
};
|
||||
|
||||
/**
|
||||
* Builder fields carried across a panel-type switch, per panel type and data source.
|
||||
*
|
||||
* The 21 combinations reduce to a handful of rules, so they are composed rather than
|
||||
* spelled out: logs and traces carry the same fields in every case, metrics splits its
|
||||
* aggregation in two, and each panel type is one of four query shapes. Order is
|
||||
* irrelevant — `handleQueryChange` copies each field independently.
|
||||
*
|
||||
* `panelTypeFormValues` in `__tests__/__fixtures__` pins the previous literal table so
|
||||
* the composition can be shown to reproduce it exactly.
|
||||
*/
|
||||
|
||||
/** Every field an aggregating query carries — shared by charts, table and pie. */
|
||||
const AGGREGATING_FIELDS = [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
] as const;
|
||||
|
||||
/** Metrics aggregates over time and then over space, so it carries both steps. */
|
||||
const METRICS_AGGREGATION = ['timeAggregation', 'spaceAggregation'] as const;
|
||||
|
||||
function omit(fields: readonly string[], ...omitted: string[]): string[] {
|
||||
return fields.filter((field) => !omitted.includes(field));
|
||||
}
|
||||
|
||||
const SERIES = [...AGGREGATING_FIELDS];
|
||||
const SERIES_METRICS = [...SERIES, ...METRICS_AGGREGATION];
|
||||
|
||||
// Table and pie reduce each series to a single cell/slice. Note the asymmetry, carried
|
||||
// over from the previous table: `reduceTo` is offered for metrics only.
|
||||
const SCALAR_METRICS = [...SERIES_METRICS, 'reduceTo'];
|
||||
|
||||
/** A single value has no series to group, limit or order. */
|
||||
const SINGLE_VALUE = [
|
||||
...omit(AGGREGATING_FIELDS, 'groupBy', 'limit', 'orderBy'),
|
||||
'reduceTo',
|
||||
];
|
||||
const SINGLE_VALUE_METRICS = [...SINGLE_VALUE, ...METRICS_AGGREGATION];
|
||||
|
||||
/** Raw rows carry no aggregation at all. */
|
||||
const RAW_ROWS = [
|
||||
'queryName',
|
||||
'filters',
|
||||
'filter',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'aggregations',
|
||||
];
|
||||
// Metrics rows drop paging and ordering too, as before.
|
||||
const RAW_ROWS_METRICS = ['queryName', 'filters', 'filter', 'aggregations'];
|
||||
|
||||
/**
|
||||
* Logs and traces share a builder surface; metrics is the one that differs.
|
||||
*
|
||||
* Each cell gets its own copy. `QueryBuilder`'s provider pushes onto the list it reads
|
||||
* from this map, so cells sharing one array instance would contaminate each other.
|
||||
*/
|
||||
function bySource(
|
||||
logsAndTraces: readonly string[],
|
||||
metrics: readonly string[],
|
||||
): Record<DataSource, any> {
|
||||
return {
|
||||
[DataSource.LOGS]: { builder: { queryData: [...logsAndTraces] } },
|
||||
[DataSource.TRACES]: { builder: { queryData: [...logsAndTraces] } },
|
||||
[DataSource.METRICS]: { builder: { queryData: [...metrics] } },
|
||||
};
|
||||
}
|
||||
|
||||
export const panelTypeDataSourceFormValuesMap: Record<
|
||||
keyof PartialPanelTypes,
|
||||
Record<DataSource, any>
|
||||
> = {
|
||||
[PANEL_TYPES.TIME_SERIES]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
|
||||
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
|
||||
[PANEL_TYPES.BAR]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'functions',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.TIME_SERIES]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'functions',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.HISTOGRAM]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'functions',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'legend',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.TABLE]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'legend',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'reduceTo',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'functions',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'legend',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.PIE]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'legend',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'reduceTo',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'functions',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'having',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'disabled',
|
||||
'queryName',
|
||||
'expression',
|
||||
'legend',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.LIST]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'queryName',
|
||||
'filters',
|
||||
'filter',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: ['queryName', 'filters', 'filter', 'aggregations'],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'queryName',
|
||||
'filters',
|
||||
'filter',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'functions',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.VALUE]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'reduceTo',
|
||||
'having',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'legend',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'having',
|
||||
'reduceTo',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'functions',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'filters',
|
||||
'filter',
|
||||
'reduceTo',
|
||||
'having',
|
||||
'functions',
|
||||
'stepInterval',
|
||||
'queryName',
|
||||
'expression',
|
||||
'disabled',
|
||||
'legend',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function handleQueryChange(
|
||||
|
||||
@@ -153,6 +153,21 @@ function TablePanelRenderer({
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => setPage(1), [searchTerm]);
|
||||
|
||||
// The measured size is only a default; without this the controlled `pageSize`
|
||||
// snaps a size-changer pick straight back to the fitted value.
|
||||
const [selectedPageSize, setSelectedPageSize] = useState<number>();
|
||||
const effectivePageSize = selectedPageSize ?? pageSize;
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(nextPage: number, nextPageSize: number): void => {
|
||||
setPage(nextPage);
|
||||
if (nextPageSize !== effectivePageSize) {
|
||||
setSelectedPageSize(nextPageSize);
|
||||
}
|
||||
},
|
||||
[effectivePageSize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -170,10 +185,10 @@ function TablePanelRenderer({
|
||||
dataSource={filteredDataSource}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
pageSize: effectivePageSize,
|
||||
hideOnSinglePage: true,
|
||||
size: 'small',
|
||||
onChange: setPage,
|
||||
onChange: handlePaginationChange,
|
||||
}}
|
||||
scroll={{ x: 'max-content', y: scrollY }}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
type DashboardtypesTablePanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
@@ -10,6 +11,7 @@ import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import { MIN_PAGE_SIZE } from '../../../utils/recordTable';
|
||||
import TablePanelRenderer from '../Renderer';
|
||||
|
||||
function panelWith(
|
||||
@@ -129,6 +131,27 @@ describe('TablePanelRenderer', () => {
|
||||
expect(queryByText('frontend')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a page size picked from the size changer', async () => {
|
||||
const rows = Array.from({ length: 60 }, (_, index): [string, number] => [
|
||||
`service-${index}`,
|
||||
index,
|
||||
]);
|
||||
const { container, getByText } = renderPanel({ data: dataWith(rows) });
|
||||
|
||||
const countRows = (): number =>
|
||||
container.querySelectorAll('.ant-table-tbody tr.ant-table-row').length;
|
||||
|
||||
expect(countRows()).toBe(MIN_PAGE_SIZE);
|
||||
|
||||
const sizeChanger = container.querySelector(
|
||||
'.ant-pagination-options .ant-select-selector',
|
||||
) as Element;
|
||||
await userEvent.click(sizeChanger);
|
||||
await userEvent.click(getByText('20 / page'));
|
||||
|
||||
expect(countRows()).toBe(20);
|
||||
});
|
||||
|
||||
it('keeps the table mounted (not No Data) when the search matches no rows', () => {
|
||||
const { getByTestId, queryByText } = renderPanel({
|
||||
data: dataWith([['frontend', 1234]]),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AutoRefreshTicker from 'container/TopNav/AutoRefreshV2/AutoRefreshTicker';
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
@@ -77,7 +78,10 @@ function DashboardContainer({
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
{!fullScreenHandle.active && (
|
||||
{fullScreenHandle.active ? (
|
||||
// The hidden toolbar owns the auto-refresh timer.
|
||||
<AutoRefreshTicker />
|
||||
) : (
|
||||
<>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />
|
||||
|
||||
Reference in New Issue
Block a user