mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-21 13:40:38 +01:00
Compare commits
10 Commits
nv/tags-or
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e93792d427 | ||
|
|
6d0f618a9e | ||
|
|
564d479aae | ||
|
|
253ca7dd7e | ||
|
|
cc45c1bef1 | ||
|
|
e70b3a0b52 | ||
|
|
f514af469e | ||
|
|
806853798d | ||
|
|
f002b29685 | ||
|
|
a5d4ef4498 |
@@ -9,6 +9,11 @@ global:
|
||||
# the path component (e.g. /signoz in https://example.com/signoz) is used
|
||||
# as the base path for all HTTP routes (both API and web frontend).
|
||||
external_url: <unset>
|
||||
# origins (scheme://host[:port], no path) allowed as login redirect targets. include
|
||||
# the origin the signoz ui is served on. when not configured, redirect targets are
|
||||
# not validated.
|
||||
# allowed_origins:
|
||||
# - https://signoz.example.com
|
||||
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
|
||||
ingestion_url: <unset>
|
||||
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
|
||||
|
||||
@@ -223,17 +223,12 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"overlayscrollbars": "^2.8.1",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
"posthog-js": "1.298.0",
|
||||
@@ -221,5 +221,18 @@
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": ">=7.29.6 <8",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
|
||||
"cookie@<0.7.0": ">=0.7.1 <1",
|
||||
"dompurify@<=3.4.10": ">=3.4.11 <4",
|
||||
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
|
||||
"js-cookie@<=3.0.5": ">=3.0.7 <4",
|
||||
"js-yaml@>=4.0.0 <=4.1.1": ">=4.2.0 <5",
|
||||
"prismjs@<1.30.0": ">=1.30.0 <2",
|
||||
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
frontend/pnpm-lock.yaml
generated
16
frontend/pnpm-lock.yaml
generated
@@ -187,11 +187,11 @@ importers:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
|
||||
overlayscrollbars:
|
||||
specifier: ^2.8.1
|
||||
version: 2.9.2
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
overlayscrollbars-react:
|
||||
specifier: ^0.5.6
|
||||
version: 0.5.6(overlayscrollbars@2.9.2)(react@18.2.0)
|
||||
version: 0.5.6(overlayscrollbars@2.16.0)(react@18.2.0)
|
||||
papaparse:
|
||||
specifier: 5.4.1
|
||||
version: 5.4.1
|
||||
@@ -6941,8 +6941,8 @@ packages:
|
||||
overlayscrollbars: ^2.0.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
overlayscrollbars@2.9.2:
|
||||
resolution: {integrity: sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA==}
|
||||
overlayscrollbars@2.16.0:
|
||||
resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
|
||||
@@ -16456,12 +16456,12 @@ snapshots:
|
||||
|
||||
outvariant@1.4.0: {}
|
||||
|
||||
overlayscrollbars-react@0.5.6(overlayscrollbars@2.9.2)(react@18.2.0):
|
||||
overlayscrollbars-react@0.5.6(overlayscrollbars@2.16.0)(react@18.2.0):
|
||||
dependencies:
|
||||
overlayscrollbars: 2.9.2
|
||||
overlayscrollbars: 2.16.0
|
||||
react: 18.2.0
|
||||
|
||||
overlayscrollbars@2.9.2: {}
|
||||
overlayscrollbars@2.16.0: {}
|
||||
|
||||
oxfmt@0.54.0:
|
||||
dependencies:
|
||||
|
||||
20
frontend/src/__tests__/fields_api.util.ts
Normal file
20
frontend/src/__tests__/fields_api.util.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
export function mockFieldsAPIsWithEmptyResponse(): void {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
import ShareURLModal from './ShareURLModal';
|
||||
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
|
||||
|
||||
import './HeaderRightSection.styles.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -29,12 +29,15 @@ interface HeaderRightSectionProps {
|
||||
enableAnnouncements: boolean;
|
||||
enableShare: boolean;
|
||||
enableFeedback: boolean;
|
||||
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
|
||||
shareModalExtraOption?: ShareURLExtraOption;
|
||||
}
|
||||
|
||||
function HeaderRightSection({
|
||||
enableAnnouncements,
|
||||
enableShare,
|
||||
enableFeedback,
|
||||
shareModalExtraOption,
|
||||
}: HeaderRightSectionProps): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -185,7 +188,7 @@ function HeaderRightSection({
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<ShareURLModal />}
|
||||
content={<ShareURLModal extraOption={shareModalExtraOption} />}
|
||||
open={openShareURLModal}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
|
||||
@@ -24,7 +24,22 @@ const routesToBeSharedWithTime = [
|
||||
ROUTES.METER_EXPLORER,
|
||||
];
|
||||
|
||||
function ShareURLModal(): JSX.Element {
|
||||
/**
|
||||
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
|
||||
* "Include variables"). When enabled, `apply` mutates the URL params that go into
|
||||
* the shared link. Keeps this shared modal generic — the page owns what it adds.
|
||||
*/
|
||||
export interface ShareURLExtraOption {
|
||||
label: string;
|
||||
defaultEnabled?: boolean;
|
||||
apply: (params: URLSearchParams) => void;
|
||||
}
|
||||
|
||||
interface ShareURLModalProps {
|
||||
extraOption?: ShareURLExtraOption;
|
||||
}
|
||||
|
||||
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
@@ -34,6 +49,9 @@ function ShareURLModal(): JSX.Element {
|
||||
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
|
||||
selectedTime !== 'custom',
|
||||
);
|
||||
const [enableExtraOption, setEnableExtraOption] = useState(
|
||||
extraOption?.defaultEnabled ?? false,
|
||||
);
|
||||
|
||||
const startTime = urlQuery.get(QueryParams.startTime);
|
||||
const endTime = urlQuery.get(QueryParams.endTime);
|
||||
@@ -93,6 +111,11 @@ function ShareURLModal(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
if (extraOption && enableExtraOption) {
|
||||
extraOption.apply(urlQuery);
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
};
|
||||
|
||||
@@ -143,6 +166,20 @@ function ShareURLModal(): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
|
||||
{extraOption && (
|
||||
<div className="absolute-relative-time-toggler-container">
|
||||
<Typography.Text className="absolute-relative-time-toggler-label">
|
||||
{extraOption.label}
|
||||
</Typography.Text>
|
||||
<div className="absolute-relative-time-toggler">
|
||||
<Switch
|
||||
value={enableExtraOption}
|
||||
onChange={(): void => setEnableExtraOption((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="share-link">
|
||||
<div className="url-share-container">
|
||||
<div className="url-share-container-header">
|
||||
|
||||
@@ -1,148 +1,39 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Drawer } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
ScrollText,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
GlobalTimeProvider,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
useGlobalTimeStore,
|
||||
} from 'store/globalTime';
|
||||
|
||||
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
|
||||
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
|
||||
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
|
||||
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
import { K8sBaseDetailsProps } from './types';
|
||||
|
||||
import '../EntityDetailsUtils/entityDetails.styles.scss';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
EntityCountConfig,
|
||||
EntityCountsSection,
|
||||
} from './components/EntityCountsSection/EntityCountsSection';
|
||||
|
||||
const TimeRangeOffset = 1000000000;
|
||||
export type {
|
||||
CustomTab,
|
||||
CustomTabRenderProps,
|
||||
K8sBaseDetailsProps,
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsFilters,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from './types';
|
||||
|
||||
export interface K8sDetailsMetadataConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => string | number;
|
||||
render?: (value: string | number, entity: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface CustomTabRenderProps<T> {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CustomTab<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
entity: T,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
queryKeyPrefix: string;
|
||||
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
|
||||
hideDetailViewTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<CustomTab<T>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetails<T>({
|
||||
category,
|
||||
eventCategory,
|
||||
@@ -163,7 +54,6 @@ export default function K8sBaseDetails<T>({
|
||||
}: K8sBaseDetailsProps<T>): JSX.Element {
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
@@ -237,86 +127,9 @@ export default function K8sBaseDetails<T>({
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
// Content state (previously in K8sBaseDetailsContent)
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
showEvents: true,
|
||||
...tabsConfig,
|
||||
}),
|
||||
[tabsConfig],
|
||||
);
|
||||
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
endTime: endMs,
|
||||
}));
|
||||
|
||||
// TODO(h4ad): Remove this and use context/zustand
|
||||
const lastSelectedInterval = useRef<Time | null>(null);
|
||||
const [selectedInterval, setSelectedInterval] = useState<Time>(
|
||||
lastSelectedInterval.current
|
||||
? lastSelectedInterval.current
|
||||
: isCustomTimeRange(selectedTime)
|
||||
? DEFAULT_TIME_RANGE
|
||||
: selectedTime,
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
if (tabVisibility.showLogs) {
|
||||
tabs.push(VIEW_TYPES.LOGS);
|
||||
}
|
||||
if (tabVisibility.showTraces) {
|
||||
tabs.push(VIEW_TYPES.TRACES);
|
||||
}
|
||||
if (tabVisibility.showEvents) {
|
||||
tabs.push(VIEW_TYPES.EVENTS);
|
||||
}
|
||||
if (customTabs) {
|
||||
tabs.push(...customTabs.map((t) => t.key));
|
||||
}
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
const [userLogsExpression] = useQueryState(
|
||||
K8S_ENTITY_LOGS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
const [userTracesExpression] = useQueryState(
|
||||
K8S_ENTITY_TRACES_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
void logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
@@ -324,136 +137,6 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [entity, eventCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
|
||||
if (!isCustomTimeRange(currentSelectedInterval)) {
|
||||
setSelectedInterval(currentSelectedInterval);
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / TimeRangeOffset),
|
||||
endTime: Math.floor(maxTime / TimeRangeOffset),
|
||||
});
|
||||
}
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
|
||||
const handleTabChange = (value: string | null): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setSelectedView(value);
|
||||
setLogFiltersParam(null);
|
||||
setTracesFiltersParam(null);
|
||||
setEventsFiltersParam(null);
|
||||
logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
lastSelectedInterval.current = interval as Time;
|
||||
setSelectedInterval(interval as Time);
|
||||
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / TimeRangeOffset),
|
||||
endTime: Math.floor(maxTime / TimeRangeOffset),
|
||||
});
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
interval,
|
||||
view: effectiveView,
|
||||
});
|
||||
},
|
||||
[eventCategory, effectiveView],
|
||||
);
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
const urlQuery = new URLSearchParams();
|
||||
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="70%"
|
||||
@@ -498,207 +181,33 @@ export default function K8sBaseDetails<T>({
|
||||
</div>
|
||||
)}
|
||||
{entity && !isEntityLoading && !hasResponseError && (
|
||||
<>
|
||||
<div className="entity-detail-drawer__entity">
|
||||
<div className="entity-details-grid">
|
||||
<div className="labels-row">
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
color="muted"
|
||||
className="entity-details-metadata-label"
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
className="entity-details-metadata-value"
|
||||
>
|
||||
{config.render ? (
|
||||
config.render(value, entity)
|
||||
) : (
|
||||
<Tooltip title={displayValue}>{displayValue}</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
<div className="views-tabs-container">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.METRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showLogs
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.LOGS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showTraces
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.TRACES,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showEvents
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.EVENTS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Events
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(customTabs?.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</div>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<Tooltip title="Go to Logs Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<Tooltip title="Go to Traces Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
selectedInterval={selectedInterval}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Metrics`}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<EntityLogs
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey={`${queryKeyPrefix}Logs`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<EntityTraces
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey={`${queryKeyPrefix}Traces`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
|
||||
<EntityEvents
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Events`}
|
||||
initialExpression={eventsInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
<React.Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
timeRange: modalTimeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
<GlobalTimeProvider
|
||||
inheritGlobalTime
|
||||
enableUrlParams={{
|
||||
relativeTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
startTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
endTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
}}
|
||||
>
|
||||
<K8sBaseDetailsContent<T>
|
||||
entity={entity}
|
||||
category={category}
|
||||
eventCategory={eventCategory}
|
||||
metadataConfig={metadataConfig}
|
||||
countsConfig={countsConfig}
|
||||
getCountsFilterExpression={getCountsFilterExpression}
|
||||
selectedItem={selectedItem}
|
||||
handleClose={handleClose}
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
queryKeyPrefix={queryKeyPrefix}
|
||||
hideDetailViewTabs={hideDetailViewTabs}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
|
||||
eventsInitialExpression={eventsInitialExpression}
|
||||
/>
|
||||
</GlobalTimeProvider>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Fragment, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { VIEW_TYPES } from '../constants';
|
||||
import { useEntityDetailsTime } from '../EntityDetailsUtils/EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
|
||||
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
|
||||
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
|
||||
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { K8sBaseDetailsContentProps } from './types';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetailsContent<T>({
|
||||
entity,
|
||||
category,
|
||||
eventCategory,
|
||||
metadataConfig,
|
||||
countsConfig,
|
||||
getCountsFilterExpression,
|
||||
selectedItem,
|
||||
handleClose,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKeyPrefix,
|
||||
hideDetailViewTabs,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
logsAndTracesInitialExpression,
|
||||
eventsInitialExpression,
|
||||
}: K8sBaseDetailsContentProps<T>): JSX.Element {
|
||||
const { timeRange, selectedInterval, handleTimeChange } =
|
||||
useEntityDetailsTime();
|
||||
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
showEvents: true,
|
||||
...tabsConfig,
|
||||
}),
|
||||
[tabsConfig],
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
if (tabVisibility.showLogs) {
|
||||
tabs.push(VIEW_TYPES.LOGS);
|
||||
}
|
||||
if (tabVisibility.showTraces) {
|
||||
tabs.push(VIEW_TYPES.TRACES);
|
||||
}
|
||||
if (tabVisibility.showEvents) {
|
||||
tabs.push(VIEW_TYPES.EVENTS);
|
||||
}
|
||||
if (customTabs) {
|
||||
tabs.push(...customTabs.map((t) => t.key));
|
||||
}
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
const [userLogsExpression] = useQueryState(
|
||||
K8S_ENTITY_LOGS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
const [userTracesExpression] = useQueryState(
|
||||
K8S_ENTITY_TRACES_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string | null): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
void setSelectedView(value);
|
||||
void setLogFiltersParam(null);
|
||||
void setTracesFiltersParam(null);
|
||||
void setEventsFiltersParam(null);
|
||||
void logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
const urlQuery = new URLSearchParams();
|
||||
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
// Explorer URL params are in milliseconds, timeRange is in seconds
|
||||
urlQuery.set(QueryParams.startTime, (timeRange.startTime * 1000).toString());
|
||||
urlQuery.set(QueryParams.endTime, (timeRange.endTime * 1000).toString());
|
||||
}
|
||||
|
||||
void logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entity-detail-drawer__entity">
|
||||
<div className="entity-details-grid">
|
||||
<div className="labels-row">
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
color="muted"
|
||||
className="entity-details-metadata-label"
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
className="entity-details-metadata-value"
|
||||
>
|
||||
{config.render ? (
|
||||
config.render(value, entity)
|
||||
) : (
|
||||
<Tooltip title={displayValue}>{displayValue}</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
<div className="views-tabs-container">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.METRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showLogs
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.LOGS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showTraces
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.TRACES,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showEvents
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.EVENTS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Events
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(customTabs?.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</div>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<Tooltip title="Go to Logs Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<Tooltip title="Go to Traces Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
eventEntity={eventCategory}
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Metrics`}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<EntityLogs
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Logs`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<EntityTraces
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Traces`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
|
||||
<EntityEvents
|
||||
eventEntity={eventCategory}
|
||||
queryKey={`${queryKeyPrefix}Events`}
|
||||
initialExpression={eventsInitialExpression}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
<Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
timeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
})}
|
||||
</Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +245,7 @@ function K8sHeader<TData>({
|
||||
showAutoRefresh={showAutoRefresh}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
defaultRelativeTime="30m"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ export function EntityCountsSection<T>({
|
||||
},
|
||||
};
|
||||
|
||||
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
@@ -60,6 +59,44 @@ export function EntityCountsSection<T>({
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
);
|
||||
const detailStartTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
);
|
||||
const detailEndTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
);
|
||||
|
||||
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
|
||||
const listStartTime = currentSearchParams.get(QueryParams.startTime);
|
||||
const listEndTime = currentSearchParams.get(QueryParams.endTime);
|
||||
|
||||
if (listRelativeTime) {
|
||||
urlParams.set(QueryParams.relativeTime, listRelativeTime);
|
||||
} else if (listStartTime && listEndTime) {
|
||||
urlParams.set(QueryParams.startTime, listStartTime);
|
||||
urlParams.set(QueryParams.endTime, listEndTime);
|
||||
}
|
||||
|
||||
if (detailRelativeTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
detailRelativeTime,
|
||||
);
|
||||
} else if (detailStartTime && detailEndTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
detailStartTime,
|
||||
);
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
detailEndTime,
|
||||
);
|
||||
}
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { EntityCountConfig } from './components/EntityCountsSection/EntityCountsSection';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export type K8sBaseFilters = {
|
||||
filter: {
|
||||
expression: string;
|
||||
@@ -33,3 +47,100 @@ export type K8sTableRowData<T> = T & {
|
||||
/** Metadata about which attributes were used for grouping */
|
||||
groupedByMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface K8sDetailsMetadataConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => string | number;
|
||||
render?: (value: string | number, entity: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface K8sDetailsWidgetInfo {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
}
|
||||
|
||||
export type GetEntityQueryPayload<T> = (
|
||||
entity: T,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
|
||||
export interface K8sDetailsTabsConfig {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
}
|
||||
|
||||
export interface K8sDetailsCustomTabRenderProps<T> {
|
||||
entity: T;
|
||||
/** Time range in seconds — see useEntityDetailsTime */
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface K8sDetailsCustomTab<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
render: (props: K8sDetailsCustomTabRenderProps<T>) => ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemExpression: (params: SelectedItemParams) => string;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: K8sDetailsWidgetInfo[];
|
||||
getEntityQueryPayload: GetEntityQueryPayload<T>;
|
||||
queryKeyPrefix: string;
|
||||
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
|
||||
hideDetailViewTabs?: boolean;
|
||||
tabsConfig?: K8sDetailsTabsConfig;
|
||||
customTabs?: K8sDetailsCustomTab<T>[];
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsContentProps<T> {
|
||||
entity: T;
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
selectedItem: string | null;
|
||||
handleClose: () => void;
|
||||
entityWidgetInfo: K8sDetailsWidgetInfo[];
|
||||
getEntityQueryPayload: GetEntityQueryPayload<T>;
|
||||
queryKeyPrefix: string;
|
||||
hideDetailViewTabs: boolean;
|
||||
tabsConfig?: K8sDetailsTabsConfig;
|
||||
customTabs?: K8sDetailsCustomTab<T>[];
|
||||
logsAndTracesInitialExpression: string;
|
||||
eventsInitialExpression: string;
|
||||
}
|
||||
|
||||
// Aliases for backward compatibility
|
||||
export type CustomTab<T> = K8sDetailsCustomTab<T>;
|
||||
export type CustomTabRenderProps<T> = K8sDetailsCustomTabRenderProps<T>;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Undo } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
|
||||
import { useEntityDetailsTime } from './useEntityDetailsTime';
|
||||
|
||||
interface EntityDateTimeSelectorProps {
|
||||
eventEntity: string;
|
||||
category: InfraMonitoringEntity;
|
||||
view: string;
|
||||
}
|
||||
|
||||
function EntityDateTimeSelector({
|
||||
eventEntity,
|
||||
category,
|
||||
view,
|
||||
}: EntityDateTimeSelectorProps): JSX.Element {
|
||||
const {
|
||||
timeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
handleResetToParentTime,
|
||||
hasTimeChanged,
|
||||
} = useEntityDetailsTime();
|
||||
|
||||
const onTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
void logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view,
|
||||
interval,
|
||||
});
|
||||
handleTimeChange(interval, dateTimeRange);
|
||||
},
|
||||
[category, view, eventEntity, handleTimeChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="entity-date-time-selector">
|
||||
{hasTimeChanged && (
|
||||
<TooltipSimple title="Reset to list time" side="bottom">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleResetToParentTime}
|
||||
data-testid="reset-to-list-time-button"
|
||||
prefix={<Undo size={14} />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection
|
||||
onTimeChange={onTimeChange}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityDateTimeSelector;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
useGlobalTime,
|
||||
} from 'store/globalTime';
|
||||
|
||||
export interface EntityDetailsTimeRange {
|
||||
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
|
||||
startTime: number;
|
||||
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface UseEntityDetailsTimeResult {
|
||||
/** Time range in seconds */
|
||||
timeRange: EntityDetailsTimeRange;
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleResetToParentTime: () => boolean;
|
||||
canResetToParent: boolean;
|
||||
hasTimeChanged: boolean;
|
||||
}
|
||||
|
||||
export function useEntityDetailsTime(): UseEntityDetailsTimeResult {
|
||||
const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
const lastComputedMinMax = useGlobalTime((s) => s.lastComputedMinMax);
|
||||
const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
|
||||
const handleResetToParentTime = useGlobalTime((s) => s.resetToParentTime);
|
||||
const parentStore = useGlobalTime((s) => s.parentStore);
|
||||
|
||||
const canResetToParent = !!parentStore;
|
||||
|
||||
const hasTimeChanged = useMemo(() => {
|
||||
if (!parentStore) {
|
||||
return false;
|
||||
}
|
||||
return selectedTime !== parentStore.getState().selectedTime;
|
||||
}, [parentStore, selectedTime]);
|
||||
|
||||
const timeRange = useMemo<EntityDetailsTimeRange>(
|
||||
() => ({
|
||||
startTime: Math.floor(
|
||||
lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER / 1000,
|
||||
),
|
||||
endTime: Math.floor(
|
||||
lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER / 1000,
|
||||
),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const selectedInterval = useMemo<Time>(
|
||||
() =>
|
||||
isCustomTimeRange(selectedTime)
|
||||
? ('custom' as Time)
|
||||
: (selectedTime as Time),
|
||||
[selectedTime],
|
||||
);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
// DateTimeSelector delivers milliseconds; the store keys custom
|
||||
// ranges by nanoseconds.
|
||||
setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
dateTimeRange[0] * NANO_SECOND_MULTIPLIER,
|
||||
dateTimeRange[1] * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedTime(interval as Time);
|
||||
}
|
||||
},
|
||||
[setSelectedTime],
|
||||
);
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
handleResetToParentTime,
|
||||
canResetToParent,
|
||||
hasTimeChanged,
|
||||
};
|
||||
}
|
||||
@@ -21,17 +21,14 @@ import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
|
||||
import EntityError from '../EntityError/EntityError';
|
||||
import { EventContents } from './EventsContent';
|
||||
@@ -53,16 +50,7 @@ interface EventDataType {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
@@ -77,13 +65,11 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
|
||||
);
|
||||
|
||||
function EntityEventsContent({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const userExpression = useUserExpression();
|
||||
@@ -131,8 +117,8 @@ function EntityEventsContent({
|
||||
if (validation.isValid) {
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.EventsView,
|
||||
@@ -141,7 +127,14 @@ function EntityEventsContent({
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
|
||||
[
|
||||
inputExpression,
|
||||
initialExpression,
|
||||
refetch,
|
||||
querySearchOnRun,
|
||||
category,
|
||||
eventEntity,
|
||||
],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
@@ -229,16 +222,10 @@ function EntityEventsContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.EventsView}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -29,25 +29,25 @@ function verifyEntityEventsV5Request({
|
||||
expect(orderKeys).toContain('timestamp');
|
||||
}
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
selectedInterval: '5m',
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('EntityEvents', () => {
|
||||
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
@@ -69,10 +69,7 @@ describe('EntityEvents', () => {
|
||||
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
|
||||
>
|
||||
<EntityEvents
|
||||
timeRange={{ startTime: 1, endTime: 2 }}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval="5m"
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityEvents from '../EntityEvents';
|
||||
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
|
||||
describe('EntityEvents time range wiring', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithEventsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
it('should query the V5 API with the global time range converted to milliseconds', async () => {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
|
||||
const expression = 'k8s.pod.name = "test-pod"';
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
expression,
|
||||
)}`}
|
||||
>
|
||||
<EntityEvents
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].start).toBe(START_MS);
|
||||
expect(capturedPayloads[0].end).toBe(END_MS);
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
@@ -38,6 +33,8 @@ import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
|
||||
import EntityError from '../EntityError/EntityError';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
@@ -47,29 +44,18 @@ import { getEntityLogsQueryPayload } from './utils';
|
||||
import styles from './EntityLogs.module.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
}
|
||||
|
||||
function EntityLogsContent({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const expression = useExpression();
|
||||
@@ -134,7 +120,7 @@ function EntityLogsContent({
|
||||
if (validation.isValid) {
|
||||
querySearchOnRun(newUserExpression);
|
||||
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
@@ -239,16 +225,10 @@ function EntityLogsContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.LogsView}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -50,25 +50,25 @@ jest.mock(
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
selectedInterval: '5m',
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('EntityLogs', () => {
|
||||
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
|
||||
const itemHeight = 100;
|
||||
@@ -94,10 +94,7 @@ describe('EntityLogs', () => {
|
||||
>
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
|
||||
<EntityLogs
|
||||
timeRange={{ startTime: 1, endTime: 2 }}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval="5m"
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityLogs from '../EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
function MockOverlayScrollbar({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
|
||||
describe('EntityLogs time range wiring', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
it('should query the V5 API with the global time range converted to milliseconds', async () => {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
|
||||
const expression = 'k8s.pod.name = "test-pod"';
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
expression,
|
||||
)}`}
|
||||
>
|
||||
<EntityLogs
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].start).toBe(START_MS);
|
||||
expect(capturedPayloads[0].end).toBe(END_MS);
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,11 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -24,25 +20,17 @@ import { getMetricsExplorerUrl } from 'utils/explorerUtils';
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
import ChartHeader from './ChartHeader';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import { useEntityMetrics } from './hooks';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
|
||||
import styles from './EntityMetrics.module.scss';
|
||||
import { MetricsTable } from './MetricsTable';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
entity: T;
|
||||
eventEntity: string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
@@ -59,16 +47,16 @@ interface EntityMetricsProps<T> {
|
||||
}
|
||||
|
||||
function EntityMetrics<T>({
|
||||
selectedInterval,
|
||||
entity,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
isModalTimeSelection,
|
||||
eventEntity,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKey,
|
||||
category,
|
||||
}: EntityMetricsProps<T>): JSX.Element {
|
||||
const { timeRange, selectedInterval, handleTimeChange } =
|
||||
useEntityDetailsTime();
|
||||
|
||||
const { visibilities, setElement } = useMultiIntersectionObserver(
|
||||
entityWidgetInfo.length,
|
||||
{ threshold: 0.1 },
|
||||
@@ -91,10 +79,8 @@ function EntityMetrics<T>({
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
handleTimeChange('custom', [startTimestamp, endTimestamp]);
|
||||
// UPlotConfigBuilder's setSelect hook already delivers milliseconds
|
||||
handleTimeChange('custom', [Math.trunc(start), Math.trunc(end)]);
|
||||
},
|
||||
[handleTimeChange],
|
||||
);
|
||||
@@ -188,16 +174,10 @@ function EntityMetrics<T>({
|
||||
return (
|
||||
<>
|
||||
<div className={styles.metricsHeader}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime={DEFAULT_TIME_RANGE}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.MetricsView}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.entityMetricsContainer}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import uPlot from 'uplot';
|
||||
@@ -28,13 +27,28 @@ jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
|
||||
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockTimeRange = { startTime: 1705315200, endTime: 1705318800 };
|
||||
let mockSelectedInterval = '5m';
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: mockTimeRange,
|
||||
selectedInterval: mockSelectedInterval,
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
|
||||
() => ({
|
||||
@@ -144,13 +158,6 @@ const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
|
||||
},
|
||||
]);
|
||||
|
||||
const mockTimeRange = {
|
||||
startTime: 1705315200,
|
||||
endTime: 1705318800,
|
||||
};
|
||||
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
|
||||
const mockQueries = [
|
||||
{
|
||||
data: {
|
||||
@@ -283,10 +290,6 @@ const mockEmptyQueries = [
|
||||
|
||||
const renderEntityMetrics = (overrides = {}): any => {
|
||||
const defaultProps = {
|
||||
timeRange: mockTimeRange,
|
||||
isModalTimeSelection: false,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
selectedInterval: '5m' as Time,
|
||||
entity: mockEntity,
|
||||
entityWidgetInfo: mockEntityWidgetInfo,
|
||||
getEntityQueryPayload: mockGetEntityQueryPayload,
|
||||
@@ -298,11 +301,8 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<EntityMetrics
|
||||
timeRange={defaultProps.timeRange}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
entity={defaultProps.entity}
|
||||
eventEntity="test"
|
||||
entityWidgetInfo={defaultProps.entityWidgetInfo}
|
||||
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
|
||||
queryKey={defaultProps.queryKey}
|
||||
@@ -344,6 +344,7 @@ const mockQueryPayloads = [
|
||||
describe('EntityMetrics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSelectedInterval = '5m';
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockQueries as any,
|
||||
chartData: mockChartData,
|
||||
@@ -454,7 +455,8 @@ describe('EntityMetrics', () => {
|
||||
});
|
||||
|
||||
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
|
||||
renderEntityMetrics({ selectedInterval: '5m' as Time });
|
||||
mockSelectedInterval = '5m';
|
||||
renderEntityMetrics();
|
||||
const href = screen
|
||||
.getByTestId('open-metrics-explorer-0')
|
||||
.getAttribute('href');
|
||||
@@ -464,7 +466,8 @@ describe('EntityMetrics', () => {
|
||||
});
|
||||
|
||||
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
|
||||
renderEntityMetrics({ selectedInterval: 'custom' as Time });
|
||||
mockSelectedInterval = 'custom';
|
||||
renderEntityMetrics();
|
||||
const href = screen
|
||||
.getByTestId('open-metrics-explorer-0')
|
||||
.getAttribute('href');
|
||||
@@ -478,7 +481,6 @@ describe('EntityMetrics', () => {
|
||||
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: 'test-query-key',
|
||||
timeRange: mockTimeRange,
|
||||
entity: mockEntity,
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
GlobalTimeStoreApi,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render } from 'tests/test-utils';
|
||||
|
||||
import { buildEntityMetricsChartConfig } from '../configBuilder';
|
||||
import EntityMetrics from '../EntityMetrics';
|
||||
|
||||
jest.mock('../configBuilder', () => ({
|
||||
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
|
||||
getId: jest.fn().mockReturnValue('mock-id'),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockBuildChartConfig = buildEntityMetricsChartConfig as jest.Mock;
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
const START_SECONDS = 1705315200;
|
||||
const END_SECONDS = 1705318800;
|
||||
|
||||
const entity = { id: 'test-entity-1' };
|
||||
|
||||
// The jsdom IntersectionObserver polyfill never reports visibility, so the
|
||||
// queries stay disabled and no network request is issued.
|
||||
const queryPayload = {
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
} as unknown as GetQueryResultsProps;
|
||||
|
||||
function createStoreWithCustomRange(): GlobalTimeStoreApi {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
function renderEntityMetrics(store: GlobalTimeStoreApi): jest.Mock {
|
||||
const getEntityQueryPayload = jest.fn().mockReturnValue([queryPayload]);
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<EntityMetrics
|
||||
entity={entity}
|
||||
eventEntity="test"
|
||||
entityWidgetInfo={[{ title: 'CPU Usage', yAxisUnit: 'percentage' }]}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
queryKey="test-query-key"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
/>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
return getEntityQueryPayload;
|
||||
}
|
||||
|
||||
describe('EntityMetrics time range wiring', () => {
|
||||
beforeEach(() => {
|
||||
mockBuildChartConfig.mockClear();
|
||||
});
|
||||
|
||||
it('should build the metrics query payloads and chart config from the global time range in seconds', () => {
|
||||
const store = createStoreWithCustomRange();
|
||||
|
||||
const getEntityQueryPayload = renderEntityMetrics(store);
|
||||
|
||||
expect(getEntityQueryPayload).toHaveBeenCalledWith(
|
||||
entity,
|
||||
START_SECONDS,
|
||||
END_SECONDS,
|
||||
false,
|
||||
);
|
||||
expect(mockBuildChartConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
minTimeScale: START_SECONDS,
|
||||
maxTimeScale: END_SECONDS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should store a drag selection (received in milliseconds) as the equivalent custom range', () => {
|
||||
const store = createStoreWithCustomRange();
|
||||
|
||||
renderEntityMetrics(store);
|
||||
|
||||
const { onDragSelect } = mockBuildChartConfig.mock.calls[0][0];
|
||||
|
||||
// UPlotConfigBuilder's setSelect hook calls onDragSelect with milliseconds
|
||||
const dragStartMs = 1705316000000;
|
||||
const dragEndMs = 1705317000000;
|
||||
act(() => {
|
||||
onDragSelect(dragStartMs, dragEndMs);
|
||||
});
|
||||
|
||||
expect(store.getState().selectedTime).toBe(
|
||||
createCustomTimeRange(
|
||||
dragStartMs * NANO_SECOND_MULTIPLIER,
|
||||
dragEndMs * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,6 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import { useQueryState } from 'nuqs';
|
||||
@@ -32,10 +27,11 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
|
||||
import EntityError from '../EntityError/EntityError';
|
||||
import { selectedEntityTracesColumns } from '../utils';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
import { isKeyNotFoundError, selectedEntityTracesColumns } from '../utils';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
|
||||
import { getTraceListColumns } from './traceListColumns';
|
||||
import { getEntityTracesQueryPayload } from './utils';
|
||||
@@ -44,29 +40,18 @@ import styles from './EntityTraces.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
eventEntity: string;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
initialExpression: string;
|
||||
}
|
||||
|
||||
function EntityTracesContent({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
selectedInterval,
|
||||
eventEntity,
|
||||
queryKey,
|
||||
category,
|
||||
}: Omit<Props, 'initialExpression'>): JSX.Element {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const userExpression = useUserExpression();
|
||||
@@ -114,8 +99,8 @@ function EntityTracesContent({
|
||||
if (validation.isValid) {
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: eventEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
@@ -124,7 +109,14 @@ function EntityTracesContent({
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
|
||||
[
|
||||
inputExpression,
|
||||
initialExpression,
|
||||
refetch,
|
||||
querySearchOnRun,
|
||||
category,
|
||||
eventEntity,
|
||||
],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
@@ -152,7 +144,7 @@ function EntityTracesContent({
|
||||
const hasAdditionalFilters = !!userExpression?.trim();
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
void logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
category,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
@@ -169,16 +161,10 @@ function EntityTracesContent({
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filterContainerTime}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={InfraMonitoringEvents.TracesView}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
|
||||
@@ -34,10 +34,8 @@ describe('EntityTraces - Default Behavior', () => {
|
||||
});
|
||||
|
||||
it('should pass time range to API (converted to milliseconds)', async () => {
|
||||
const timeRange = { startTime: 1000, endTime: 2000 };
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces({ timeRange });
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -47,8 +45,8 @@ describe('EntityTraces - Default Behavior', () => {
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedTimeRange: {
|
||||
start: timeRange.startTime * 1000,
|
||||
end: timeRange.endTime * 1000,
|
||||
start: 1 * 1000,
|
||||
end: 2 * 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
createGlobalTimeStore,
|
||||
GlobalTimeContext,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityTraces from '../EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
|
||||
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
|
||||
|
||||
describe('EntityTraces time range wiring', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
it('should query the V5 API with the global time range converted to milliseconds', async () => {
|
||||
const store = createGlobalTimeStore();
|
||||
store
|
||||
.getState()
|
||||
.setSelectedTime(
|
||||
createCustomTimeRange(
|
||||
START_MS * NANO_SECOND_MULTIPLIER,
|
||||
END_MS * NANO_SECOND_MULTIPLIER,
|
||||
),
|
||||
);
|
||||
|
||||
const expression = 'k8s.pod.name = "test-pod"';
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
expression,
|
||||
)}`}
|
||||
>
|
||||
<EntityTraces
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>
|
||||
</GlobalTimeContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].start).toBe(START_MS);
|
||||
expect(capturedPayloads[0].end).toBe(END_MS);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
@@ -9,39 +7,19 @@ import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
import EntityTraces from '../EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
// QuerySearch fires autocomplete requests on mount; without handlers MSW
|
||||
// passes them through to the real network and the resulting AxiosError fails
|
||||
// whichever test happens to be running.
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
mockFieldsAPIsWithEmptyResponse();
|
||||
});
|
||||
|
||||
export interface RenderEntityTracesOptions {
|
||||
expression?: string;
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
category?: InfraMonitoringEntity;
|
||||
selectedInterval?: '5m' | '15m' | '30m' | '1h';
|
||||
pagination?: { offset: number; limit: number };
|
||||
}
|
||||
|
||||
export function renderEntityTraces({
|
||||
expression = 'k8s.pod.name = "test-pod"',
|
||||
timeRange = { startTime: 1, endTime: 2 },
|
||||
category = InfraMonitoringEntity.PODS,
|
||||
selectedInterval = '5m',
|
||||
pagination,
|
||||
}: RenderEntityTracesOptions = {}): RenderResult {
|
||||
const encodedExpression = encodeURIComponent(expression);
|
||||
@@ -55,10 +33,7 @@ export function renderEntityTraces({
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<EntityTraces
|
||||
timeRange={timeRange}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval={selectedInterval}
|
||||
eventEntity="test"
|
||||
queryKey="test"
|
||||
category={category}
|
||||
initialExpression={expression}
|
||||
@@ -101,21 +76,21 @@ export function verifyQueryPayload({
|
||||
expect(orderKeys).toContain('timestamp');
|
||||
}
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="mock-datetime-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
|
||||
useEntityDetailsTime: (): {
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: string;
|
||||
handleTimeChange: jest.Mock;
|
||||
} => ({
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
selectedInterval: '5m',
|
||||
handleTimeChange: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import { CustomTab } from '../Base/K8sBaseDetails';
|
||||
@@ -10,6 +11,20 @@ import {
|
||||
|
||||
import EntityMetrics from './EntityMetrics';
|
||||
|
||||
const categoryToEventEntity: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.DAEMONSETS]: InfraMonitoringEvents.DaemonSet,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: InfraMonitoringEvents.Deployment,
|
||||
[InfraMonitoringEntity.JOBS]: InfraMonitoringEvents.Job,
|
||||
[InfraMonitoringEntity.NAMESPACES]: InfraMonitoringEvents.Namespace,
|
||||
[InfraMonitoringEntity.STATEFULSETS]: InfraMonitoringEvents.StatefulSet,
|
||||
[InfraMonitoringEntity.PODS]: InfraMonitoringEvents.Pod,
|
||||
[InfraMonitoringEntity.NODES]: InfraMonitoringEvents.Node,
|
||||
[InfraMonitoringEntity.CLUSTERS]: InfraMonitoringEvents.Cluster,
|
||||
[InfraMonitoringEntity.VOLUMES]: InfraMonitoringEvents.Volume,
|
||||
[InfraMonitoringEntity.HOSTS]: InfraMonitoringEvents.HostEntity,
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'container',
|
||||
};
|
||||
|
||||
interface CreatePodMetricsTabParams<T> {
|
||||
getQueryPayload: (
|
||||
entity: T,
|
||||
@@ -17,30 +32,29 @@ interface CreatePodMetricsTabParams<T> {
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
category: InfraMonitoringEntity;
|
||||
queryKey: string;
|
||||
category: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
export function createPodMetricsTab<T>({
|
||||
getQueryPayload,
|
||||
category,
|
||||
queryKey,
|
||||
category,
|
||||
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
|
||||
const eventEntity = categoryToEventEntity[category];
|
||||
|
||||
return {
|
||||
key: VIEW_TYPES.POD_METRICS,
|
||||
label: 'Pod Metrics',
|
||||
icon: <Container size={14} />,
|
||||
render: ({ entity, timeRange, selectedInterval, handleTimeChange }) => (
|
||||
render: ({ entity }) => (
|
||||
<EntityMetrics
|
||||
entity={entity}
|
||||
selectedInterval={selectedInterval}
|
||||
timeRange={timeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection
|
||||
eventEntity={eventEntity}
|
||||
entityWidgetInfo={podUtilizationByPodWidgetInfo}
|
||||
getEntityQueryPayload={getQueryPayload}
|
||||
category={category}
|
||||
queryKey={queryKey}
|
||||
category={category}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -454,3 +454,9 @@
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-date-time-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -905,6 +905,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
SELECTED_ITEM: 'selectedItem',
|
||||
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
|
||||
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
|
||||
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
|
||||
DETAIL_START_TIME: 'detailStartTime',
|
||||
DETAIL_END_TIME: 'detailEndTime',
|
||||
};
|
||||
|
||||
/** Metric namespace prefixes for /fields/keys and /fields/values APIs */
|
||||
|
||||
@@ -5262,6 +5262,24 @@ const onboardingConfigWithLinks = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'temporal-cloud-metrics',
|
||||
label: 'Temporal Cloud Metrics',
|
||||
imgUrl: temporalUrl,
|
||||
tags: ['metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'metrics',
|
||||
'integrations',
|
||||
'temporal',
|
||||
'temporal cloud',
|
||||
'temporal cloud metrics',
|
||||
'temporal metrics',
|
||||
'openmetrics',
|
||||
'prometheus',
|
||||
],
|
||||
link: '/docs/integrations/temporal-cloud-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'temporal',
|
||||
label: 'Temporal',
|
||||
@@ -5273,9 +5291,6 @@ const onboardingConfigWithLinks = [
|
||||
'application performance monitoring',
|
||||
'integrations',
|
||||
'temporal',
|
||||
'temporal cloud',
|
||||
'temporal logs',
|
||||
'temporal metrics',
|
||||
'temporal traces',
|
||||
'traces',
|
||||
'tracing',
|
||||
@@ -5284,12 +5299,6 @@ const onboardingConfigWithLinks = [
|
||||
desc: 'What are you using ?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'temporal-cloud',
|
||||
label: 'Cloud Metrics',
|
||||
imgUrl: temporalUrl,
|
||||
link: '/docs/integrations/temporal-cloud-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'temporal-golang',
|
||||
label: 'Go',
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -39,6 +39,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
signalSource,
|
||||
setAttributeKeys,
|
||||
}: AgregatorFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
|
||||
|
||||
@@ -289,7 +290,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
placeholder={getPlaceholder()}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { categoryToSupport } from './config';
|
||||
import { selectStyles } from './styles';
|
||||
@@ -13,6 +13,7 @@ function BuilderUnitsFilter({
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
}: IBuilderUnitsFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
|
||||
|
||||
const selectedValue = yAxisUnit || currentQuery?.unit;
|
||||
@@ -36,7 +37,7 @@ function BuilderUnitsFilter({
|
||||
Y-axis unit
|
||||
</Typography.Text>
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
style={selectStyles}
|
||||
onChange={onChangeHandler}
|
||||
value={selectedValue}
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
} from 'lib/query/transformQueryBuilderData';
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../../utils';
|
||||
import { HavingFilterProps, HavingTagRenderProps } from './types';
|
||||
|
||||
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = formula;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [localValues, setLocalValues] = useState<string[]>([]);
|
||||
@@ -171,7 +172,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearch/config';
|
||||
import { OrderByProps } from './types';
|
||||
@@ -13,6 +13,7 @@ function OrderByFilter({
|
||||
onChange,
|
||||
query,
|
||||
}: OrderByProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
createOptions,
|
||||
@@ -64,7 +65,7 @@ function OrderByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -33,6 +33,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
disabled,
|
||||
signalSource,
|
||||
}: GroupByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [optionsData, setOptionsData] = useState<
|
||||
@@ -174,7 +175,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../utils';
|
||||
// ** Types
|
||||
@@ -27,6 +27,7 @@ export function HavingFilter({
|
||||
query,
|
||||
onChange,
|
||||
}: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = query;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
|
||||
@@ -231,7 +232,7 @@ export function HavingFilter({
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -85,6 +85,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
signalSource,
|
||||
'data-testid': dataTestId,
|
||||
}: MetricNameSelectorProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const currentMetricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
@@ -272,7 +273,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
return (
|
||||
<AutoComplete
|
||||
className="metric-name-selector"
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { OrderByFilterProps } from './OrderByFilter.interfaces';
|
||||
@@ -16,6 +16,7 @@ export function OrderByFilter({
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
@@ -78,7 +79,7 @@ export function OrderByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { FeatureKeys } from '../../../../constants/features';
|
||||
@@ -95,6 +95,7 @@ function QueryBuilderSearch({
|
||||
disableNavigationShortcuts,
|
||||
entity,
|
||||
}: QueryBuilderSearchProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { pathname } = useLocation();
|
||||
const isLogsExplorerPage = useMemo(
|
||||
() => pathname === ROUTES.LOGS_EXPLORER,
|
||||
@@ -397,7 +398,7 @@ function QueryBuilderSearch({
|
||||
<Select
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
transitionName=""
|
||||
choiceTransitionName=""
|
||||
virtual={false}
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
@@ -157,6 +157,8 @@ function QueryBuilderSearchV2(
|
||||
selectProps,
|
||||
} = props;
|
||||
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { handleRunQuery, currentQuery } = useQueryBuilder();
|
||||
@@ -989,7 +991,7 @@ function QueryBuilderSearchV2(
|
||||
{...selectProps}
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
|
||||
{...(hasPopupContainer ? { getPopupContainer } : {})}
|
||||
{...(maxTagCount ? { maxTagCount } : {})}
|
||||
key={queryTags.join('.')}
|
||||
virtual={false}
|
||||
|
||||
@@ -7,6 +7,7 @@ import AppRoutes from 'AppRoutes';
|
||||
import { AxiosError } from 'axios';
|
||||
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
@@ -17,6 +18,8 @@ import './ReactI18';
|
||||
|
||||
import 'styles.scss';
|
||||
|
||||
configureOverlayScrollbars();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
22
frontend/src/lib/configureOverlayScrollbars.ts
Normal file
22
frontend/src/lib/configureOverlayScrollbars.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { OverlayScrollbars } from 'overlayscrollbars';
|
||||
|
||||
/**
|
||||
* Disables the content `elementEvents` option (default: `[['img', 'load']]`)
|
||||
* for every OverlayScrollbars instance.
|
||||
*
|
||||
* The per-element event cleanups OverlayScrollbars stores in its internal
|
||||
* WeakMap close over the MutationObserver callback scope, which holds arrays
|
||||
* with every node added/removed in that mutation batch. A single long-lived
|
||||
* reference to one of those elements (e.g. CodeMirror's EditContext or its
|
||||
* module-level scratch Range pinning a detached editor) then retains entire
|
||||
* unmounted subtrees as detached DOM — ~1.3k nodes per InfraMonitoring
|
||||
* category switch.
|
||||
*
|
||||
* Content size changes from loading images are still handled by the
|
||||
* scrollbars' size observer, so scrollbar geometry stays correct.
|
||||
*/
|
||||
export function configureOverlayScrollbars(): void {
|
||||
OverlayScrollbars.env().setDefaultOptions({
|
||||
update: { elementEvents: null },
|
||||
});
|
||||
}
|
||||
@@ -126,6 +126,15 @@ export default function UPlotChart({
|
||||
}
|
||||
}, [isDataEmpty, destroyPlot]);
|
||||
|
||||
/**
|
||||
* Destroy the plot on unmount. Without this, uPlot's window-level
|
||||
* `dppxchange` listener keeps the instance (and its whole detached DOM
|
||||
* subtree) alive after the component is gone.
|
||||
*/
|
||||
const destroyPlotRef = useRef(destroyPlot);
|
||||
destroyPlotRef.current = destroyPlot;
|
||||
useEffect(() => (): void => destroyPlotRef.current(), []);
|
||||
|
||||
/**
|
||||
* Handle initialization and prop changes
|
||||
*/
|
||||
|
||||
@@ -327,6 +327,32 @@ describe('UPlotChart', () => {
|
||||
expect(firstInstance.destroy).toHaveBeenCalled();
|
||||
expect(instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('destroys the instance and notifies callbacks on unmount', () => {
|
||||
const plotRef = jest.fn();
|
||||
const onDestroy = jest.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
plotRef={plotRef}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const firstInstance = instances[0];
|
||||
plotRef.mockClear();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
|
||||
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(plotRef).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanGaps data transformation', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { ColorPicker } from 'antd';
|
||||
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
@@ -11,11 +12,11 @@ interface ThresholdColorSelectProps {
|
||||
|
||||
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
|
||||
// as quick swatches in the picker; the full picker below covers any custom color.
|
||||
const PRESETS: { label: string; value: string }[] = [
|
||||
{ label: 'Red', value: '#F1575F' },
|
||||
{ label: 'Orange', value: '#F5B225' },
|
||||
{ label: 'Green', value: '#2BB673' },
|
||||
{ label: 'Blue', value: '#4E74F8' },
|
||||
const PRESETS: { label: string; value: ThresholdColor }[] = [
|
||||
{ label: 'Red', value: ThresholdColor.RED },
|
||||
{ label: 'Orange', value: ThresholdColor.ORANGE },
|
||||
{ label: 'Green', value: ThresholdColor.GREEN },
|
||||
{ label: 'Blue', value: ThresholdColor.BLUE },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
@@ -22,7 +23,7 @@ import TableThresholdRow from './rows/TableThresholdRow';
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
// New thresholds default to red (the first palette preset); the user recolors per rule.
|
||||
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
|
||||
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
|
||||
|
||||
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
|
||||
const ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
|
||||
@@ -73,6 +73,25 @@ describe('usePanelEditorDraft', () => {
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
|
||||
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
|
||||
// against the persisted panel (`saved`) — so it starts dirty, not clean.
|
||||
const seed = panel('Memory', 'usage');
|
||||
const saved = panel('CPU', 'usage');
|
||||
|
||||
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean when the seed matches the saved baseline', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
// Exercises the REAL query builder provider (not mocks) so the dirty check is
|
||||
// verified against the builder's actual re-serialization — the "always dirty"
|
||||
// regression only reproduces with the real normalization in the loop.
|
||||
|
||||
const panelType = PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
function makeSavedQueries(): DashboardtypesQueryDTO[] {
|
||||
const base: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return toPerses(base, panelType);
|
||||
}
|
||||
|
||||
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
it('an untouched existing panel is NOT query-dirty on mount', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(saved),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
// The builder force-resets to the saved query asynchronously; once settled the
|
||||
// live query must serialize back to the saved queries → clean.
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
// And stays clean (no late re-stage flips it dirty).
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
|
||||
// An older saved query carries only a few fields; the builder re-emits many more
|
||||
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
|
||||
// this as always-dirty; the round-tripped baseline normalizes both sides.
|
||||
const minimalSaved: DashboardtypesQueryDTO[] = [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [
|
||||
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(minimalSaved),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: minimalSaved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
|
||||
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
|
||||
// carries the last-run (edited) query. The builder must hydrate from the URL —
|
||||
// not discard it — so the edit survives, and it must read dirty against saved.
|
||||
const saved = makeSavedQueries();
|
||||
const editedInUrl: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
id: 'edited-in-url',
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu-edited-in-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(editedInUrl)),
|
||||
);
|
||||
const setSpec = jest.fn();
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`/?${params.toString()}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
// The draft/preview open on the saved query…
|
||||
draft: makePanel(saved),
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// The URL edit is retained → dirty, and it's synced into the draft so the
|
||||
// preview follows (setSpec called with the edited query).
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
expect(setSpec).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
const editedBase: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu-edited',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const editedQueries = toPerses(editedBase, panelType);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
// The builder seeds from the draft (the handed-off edit)…
|
||||
draft: makePanel(editedQueries),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
// …but the baseline is the persisted panel.
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ describe('usePanelEditorQuerySync', () => {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
@@ -119,20 +120,22 @@ describe('usePanelEditorQuerySync', () => {
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
savedQueries: opts.savedQueries,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
|
||||
// no query, so an in-editor edit in the URL survives a refresh.
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,44 +345,127 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
describe('staged-query re-sync (browser back/forward)', () => {
|
||||
it('commits the staged query into the draft when it re-stages', () => {
|
||||
const state = builderState();
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Browser Back re-stages a different query via initQueryBuilderData; the
|
||||
// preview must follow it instead of keeping the last Run's result.
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
state.stagedQuery = {
|
||||
id: 'restaged',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when only the live query changes (no re-stage)', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Live edit: currentQuery changes, staged query + structure unchanged.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
|
||||
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
|
||||
// toPerses so the envelope comparison — not getIsQueryModified — decides.
|
||||
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const EDITED_ENVELOPES = [
|
||||
{ id: 'edited-envelopes' },
|
||||
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
|
||||
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
|
||||
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
|
||||
);
|
||||
});
|
||||
|
||||
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
it('is not query-dirty when the live query still serializes to the saved queries', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
queries: EDITED_ENVELOPES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
|
||||
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
|
||||
// The draft carries the View-mode edit (the builder seeds from it), but the
|
||||
// baseline is the persisted panel: a live query equal to the edited draft
|
||||
// still reads dirty against the saved queries.
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const draft = makeDraft(EDITED_ENVELOPES);
|
||||
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,12 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
|
||||
* panel or the drilldown modal, where the seed is the baseline.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
@@ -67,12 +73,15 @@ export interface UsePanelEditSessionReturn {
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
|
||||
panel,
|
||||
savedPanel,
|
||||
);
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
@@ -93,6 +102,7 @@ export function usePanelEditSession({
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
savedQueries: savedPanel?.spec.queries,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
|
||||
@@ -13,9 +13,14 @@ import type { PanelEditorDraftApi } from '../types';
|
||||
* preview renders it through the dashboard's renderer registry and the save hook
|
||||
* patches it without conversion. Everything the config pane edits flows through the
|
||||
* single `spec`/`setSpec` pair.
|
||||
*
|
||||
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
|
||||
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
|
||||
* Defaults to the seed when there's no separate saved baseline (a new panel).
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
savedPanel: DashboardtypesPanelDTO = initialPanel,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
@@ -35,9 +40,9 @@ export function usePanelEditorDraft(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, initialPanel],
|
||||
[draft, savedPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -27,6 +28,12 @@ interface UsePanelEditorQuerySyncArgs {
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/**
|
||||
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
|
||||
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
|
||||
* for a new panel, where the seed query is the baseline.
|
||||
*/
|
||||
savedQueries?: DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
@@ -53,43 +60,31 @@ export function usePanelEditorQuerySync({
|
||||
refetch,
|
||||
alwaysSerializeQuery = false,
|
||||
signal,
|
||||
savedQueries,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
const savedQueries = draft.spec.queries;
|
||||
const draftQueries = draft.spec.queries;
|
||||
|
||||
// A new panel has no saved query: seed from the kind's first supported signal
|
||||
// instead of letting `fromPerses` fall back to the metrics default (which List
|
||||
// doesn't support).
|
||||
// A new panel has no saved query: seed from the kind's first supported signal rather
|
||||
// than `fromPerses`'s metrics default (which List doesn't support).
|
||||
const seedQuery = useMemo(
|
||||
() =>
|
||||
savedQueries.length === 0 && signal
|
||||
draftQueries.length === 0 && signal
|
||||
? initialQueriesMap[signal]
|
||||
: fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType, signal],
|
||||
: fromPerses(draftQueries, panelType),
|
||||
[draftQueries, panelType, signal],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding a
|
||||
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
|
||||
// baseline is captured from the URL). After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
// No forceReset: seed the builder only when the URL carries no query, so an
|
||||
// in-editor edit in the URL survives a refresh / browser Back-Forward.
|
||||
useShareBuilderUrl({ defaultValue: seedQuery });
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query, which can carry stale state across a refresh and read a real
|
||||
// switch as "unchanged". Returns whether the draft changed.
|
||||
// Commit the live query into the draft (what the preview fetches).
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: savedQueries;
|
||||
: draftQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so a structural compare would falsely dirty the draft.
|
||||
@@ -100,7 +95,7 @@ export function usePanelEditorQuerySync({
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
@@ -110,7 +105,7 @@ export function usePanelEditorQuerySync({
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
// mount: the initial query is synced into the draft by the staged-query effect below.
|
||||
const dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
[currentQuery.builder],
|
||||
@@ -136,6 +131,15 @@ export function usePanelEditorQuerySync({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Follow the staged (executed) query into the draft on a URL re-stage (mount
|
||||
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
|
||||
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
|
||||
useEffect(() => {
|
||||
if (stagedQuery) {
|
||||
commitRef.current(stagedQuery);
|
||||
}
|
||||
}, [stagedQuery]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
@@ -144,20 +148,29 @@ export function usePanelEditorQuerySync({
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset) — comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
|
||||
// once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
|
||||
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
|
||||
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
|
||||
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
|
||||
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
|
||||
const baselineEnvelopes = useMemo(
|
||||
() =>
|
||||
toQueryEnvelopes(
|
||||
toPerses(
|
||||
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
|
||||
panelType,
|
||||
),
|
||||
),
|
||||
[savedQueries, seedQuery, panelType],
|
||||
);
|
||||
const isQueryDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
toQueryEnvelopes(toPerses(currentQuery, panelType)),
|
||||
baselineEnvelopes,
|
||||
),
|
||||
[currentQuery, panelType, baselineEnvelopes],
|
||||
);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
@@ -41,10 +42,22 @@ import styles from './PanelEditor.module.scss';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
// Portal them to the document body; the query-builder filters honor this via
|
||||
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
|
||||
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
|
||||
const getBodyPopupContainer = (): HTMLElement => document.body;
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
@@ -68,6 +81,7 @@ function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
@@ -91,6 +105,7 @@ function PanelEditorContainer({
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
@@ -288,22 +303,24 @@ function PanelEditorContainer({
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,22 @@ export interface ComparisonThresholdShape {
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
}
|
||||
|
||||
/** SigNoz threshold palette; single source of truth for the hex values. */
|
||||
export enum ThresholdColor {
|
||||
RED = '#F1575F',
|
||||
ORANGE = '#F5B225',
|
||||
GREEN = '#2BB673',
|
||||
BLUE = '#4E74F8',
|
||||
}
|
||||
|
||||
/** Palette ordered most-dangerous first (preset order + alert-severity ranking). */
|
||||
export const THRESHOLD_COLOR_DANGER_ORDER: ThresholdColor[] = [
|
||||
ThresholdColor.RED,
|
||||
ThresholdColor.ORANGE,
|
||||
ThresholdColor.GREEN,
|
||||
ThresholdColor.BLUE,
|
||||
];
|
||||
|
||||
/** Comparison operators a threshold can use, as evaluable symbols. */
|
||||
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ function ViewPanelModalContent({
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
|
||||
openPanelEditor(panelId, {
|
||||
handoffState: { editSpec: buildSaveSpec(draft.spec) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { normalizeOperator } from 'container/CreateAlertV2/context/conditionNormalizers';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
Threshold,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
import type { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -21,14 +23,6 @@ export interface PanelAlertPrefill {
|
||||
threshold?: Threshold;
|
||||
}
|
||||
|
||||
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
|
||||
const THRESHOLD_COLOR_DANGER_ORDER = [
|
||||
'#f1575f',
|
||||
'#f5b225',
|
||||
'#2bb673',
|
||||
'#4e74f8',
|
||||
];
|
||||
|
||||
interface NormalizedPanelThreshold {
|
||||
color: string;
|
||||
value: number;
|
||||
@@ -93,8 +87,12 @@ function readPanelThresholds(
|
||||
}
|
||||
}
|
||||
|
||||
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
|
||||
function colorRank(color: string): number {
|
||||
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
|
||||
const target = color.toLowerCase();
|
||||
const index = THRESHOLD_COLOR_DANGER_ORDER.findIndex(
|
||||
(paletteColor) => paletteColor.toLowerCase() === target,
|
||||
);
|
||||
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
|
||||
}
|
||||
|
||||
@@ -106,22 +104,17 @@ function pickHighestDanger(
|
||||
)[0];
|
||||
}
|
||||
|
||||
// The alert UI has no inclusive operator; collapse "or equal" onto its strict variant.
|
||||
function panelOperatorToAlertOperator(
|
||||
operator: DashboardtypesComparisonOperatorDTO | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (operator) {
|
||||
case 'above':
|
||||
case 'above_or_equal':
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case 'below':
|
||||
return normalizeOperator('above');
|
||||
case 'below_or_equal':
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case 'equal':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case 'not_equal':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
return normalizeOperator('below');
|
||||
default:
|
||||
return undefined;
|
||||
return normalizeOperator(operator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
import { useShareVariablesOption } from './useShareVariablesOption';
|
||||
|
||||
import styles from './DashboardPageHeader.module.scss';
|
||||
|
||||
@@ -14,10 +15,16 @@ function DashboardPageHeader({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
const shareVariablesOption = useShareVariablesOption();
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
shareModalExtraOption={shareVariablesOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ShareURLExtraOption } from 'components/HeaderRightSection/ShareURLModal';
|
||||
|
||||
import type { SelectedVariableValue } from '../../VariablesBar/selectionTypes';
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from '../../VariablesBar/utils/variablesUrlState';
|
||||
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* The share-dialog "Include variables" option: serializes the current variable
|
||||
* selection into the `?variables=` param (ALL encoded as the sentinel) so a shared
|
||||
* link reproduces it for the recipient — who hydrates it into local storage on load,
|
||||
* after which the param is cleared (see useSeedVariableSelection). Returns undefined
|
||||
* when there is nothing selected to share.
|
||||
*/
|
||||
export function useShareVariablesOption(): ShareURLExtraOption | undefined {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const selections = useDashboardStore(selectVariableValues(dashboardId ?? ''));
|
||||
|
||||
return useMemo(() => {
|
||||
const names = Object.keys(selections);
|
||||
if (names.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const urlShape: Record<string, SelectedVariableValue> = {};
|
||||
names.forEach((name) => {
|
||||
const selection = selections[name];
|
||||
urlShape[name] = selection.allSelected ? ALL_SELECTED : selection.value;
|
||||
});
|
||||
const serialized = variablesUrlParser.serialize(urlShape);
|
||||
return {
|
||||
label: 'Include variables',
|
||||
apply: (params): void => {
|
||||
params.set('variables', serialized);
|
||||
},
|
||||
};
|
||||
}, [selections]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useCreatePanel } from '../useCreatePanel';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
describe('useCreatePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
|
||||
});
|
||||
|
||||
it('carries the relative time window onto the new-panel route', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('/dashboard/dash-1/panel/new');
|
||||
expect(url).toContain('panelKind=timeSeries');
|
||||
expect(url).toContain('layoutIndex=2');
|
||||
expect(url).toContain('relativeTime=6h');
|
||||
});
|
||||
|
||||
it('carries a custom absolute window and never a stray relativeTime', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('startTime=1000');
|
||||
expect(url).toContain('endTime=2000');
|
||||
expect(url).not.toContain('relativeTime');
|
||||
expect(url).not.toContain('&&');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useOpenPanelEditor } from '../useOpenPanelEditor';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
describe('useOpenPanelEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
|
||||
});
|
||||
|
||||
it('carries the relative time window into the editor route', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries a custom absolute window as a start/end ms pair', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('startTime=1000');
|
||||
expect(url).toContain('endTime=2000');
|
||||
// A custom range must not also carry relativeTime (it would win on the editor).
|
||||
expect(url).not.toContain('relativeTime');
|
||||
});
|
||||
|
||||
it('omits the query string for an uninitialized custom window', () => {
|
||||
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards handoff state as router location state', () => {
|
||||
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
const handoffState = { editSpec: { title: 'x' } } as never;
|
||||
result.current('panel-9', { handoffState });
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
|
||||
{ state: handoffState },
|
||||
);
|
||||
});
|
||||
|
||||
it('merges search with the time window (leading ? tolerated)', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('/dashboard/dash-1/panel/new?');
|
||||
expect(url).toContain('panelKind=timeSeries');
|
||||
expect(url).toContain('layoutIndex=2');
|
||||
expect(url).toContain('relativeTime=6h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
describe('useTimeSearchParams', () => {
|
||||
it('returns a relativeTime query string for a relative selection', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toBe('relativeTime=6h');
|
||||
});
|
||||
|
||||
it('returns an absolute ms pair for a custom selection', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toContain('startTime=1000');
|
||||
expect(result.current).toContain('endTime=2000');
|
||||
expect(result.current).not.toContain('relativeTime');
|
||||
});
|
||||
|
||||
it('returns an empty string for an uninitialized custom window', () => {
|
||||
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
|
||||
import type { PanelKind } from '../Panels/types/panelKind';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useOpenPanelEditor } from './useOpenPanelEditor';
|
||||
|
||||
interface UseCreatePanelResult {
|
||||
isPickerOpen: boolean;
|
||||
@@ -24,8 +21,7 @@ interface UseCreatePanelResult {
|
||||
* until save.
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
// Captured on open, consumed on select.
|
||||
@@ -43,15 +39,12 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
// Variable selection is read from the persisted store, not the URL.
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
openPanelEditor(NEW_PANEL_ID, {
|
||||
search: newPanelSearch(panelKind, target),
|
||||
});
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
[openPanelEditor, layoutIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,30 +5,39 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id. Variable selection is read from the
|
||||
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
|
||||
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
|
||||
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
|
||||
* panel.
|
||||
*/
|
||||
interface OpenPanelEditorOptions {
|
||||
handoffState?: PanelEditorHandoffState;
|
||||
/** Extra query merged into the editor URL (leading `?` optional). */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/** Opens the V2 panel editor, carrying the active time window in the URL. */
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
handoffState?: PanelEditorHandoffState,
|
||||
options?: OpenPanelEditorOptions,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
|
||||
(panelId: string, options?: OpenPanelEditorOptions): void => {
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
});
|
||||
const params = new URLSearchParams(options?.search);
|
||||
new URLSearchParams(timeSearch).forEach((value, key) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
const search = params.toString();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
search ? `${path}?${search}` : path,
|
||||
options?.handoffState ? { state: options.handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
[safeNavigate, dashboardId, timeSearch],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
|
||||
|
||||
/** Active time window as a query string (no leading `?`), or `''` when unset. */
|
||||
export function useTimeSearchParams(): string {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
return useMemo(
|
||||
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
|
||||
[selectedTime, minTime, maxTime],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../timeUrlParams';
|
||||
|
||||
describe('timeParamsFromGlobalTime', () => {
|
||||
it('emits relativeTime for a relative selection', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: '6h',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.get('relativeTime')).toBe('6h');
|
||||
// Mutually exclusive: no absolute pair alongside a relative range.
|
||||
expect(params.has('startTime')).toBe(false);
|
||||
expect(params.has('endTime')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
});
|
||||
|
||||
expect(params.get('startTime')).toBe('1000');
|
||||
expect(params.get('endTime')).toBe('2000');
|
||||
// A custom range must not carry a relativeTime that would win on the editor.
|
||||
expect(params.has('relativeTime')).toBe(false);
|
||||
});
|
||||
|
||||
it('carries a custom shorthand relative selection verbatim', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: '13m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.get('relativeTime')).toBe('13m');
|
||||
});
|
||||
|
||||
it('emits nothing for an uninitialized custom window', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: 'custom',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.toString()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
type GlobalTimeSelection = Pick<
|
||||
GlobalReducer,
|
||||
'selectedTime' | 'minTime' | 'maxTime'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Time-window URL params for the active selection. Derived from Redux (what the picker and
|
||||
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
|
||||
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
|
||||
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
|
||||
*/
|
||||
export function timeParamsFromGlobalTime({
|
||||
selectedTime,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: GlobalTimeSelection): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedTime === 'custom') {
|
||||
if (minTime > 0 && maxTime > 0) {
|
||||
params.set(
|
||||
QueryParams.startTime,
|
||||
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.endTime,
|
||||
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
params.set(QueryParams.relativeTime, selectedTime);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
@@ -38,6 +39,7 @@ function PanelEditorPage(): JSX.Element {
|
||||
}>();
|
||||
const { search, state } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
@@ -105,10 +107,11 @@ function PanelEditorPage(): JSX.Element {
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
|
||||
// variable selection from the persisted store, and time lives in Redux.
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [safeNavigate, dashboardId]);
|
||||
// Drop editor-only URL state (variables come from the persisted store), but carry
|
||||
// time so a custom range picked in the editor isn't reset to the dashboard default.
|
||||
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
|
||||
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
|
||||
}, [safeNavigate, dashboardId, timeSearch]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
@@ -137,6 +140,7 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId={dashboardId}
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
savedPanel={existingPanel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
isEditable={isEditable}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function GlobalTimeProvider({
|
||||
name,
|
||||
selectedTime: resolveInitialTime(),
|
||||
refreshInterval: initialRefreshInterval ?? 0,
|
||||
parentStore: inheritGlobalTime ? globalStore : undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -38,6 +38,36 @@ const createWrapper = (
|
||||
};
|
||||
};
|
||||
|
||||
const createNestedWrapper = (
|
||||
parentProps: GlobalTimeProviderOptions,
|
||||
childProps: GlobalTimeProviderOptions,
|
||||
nuqsProps?: {
|
||||
searchParams?: string;
|
||||
onUrlUpdate?: (event: { queryString: string }) => void;
|
||||
},
|
||||
) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return function NestedWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={nuqsProps?.searchParams}
|
||||
onUrlUpdate={nuqsProps?.onUrlUpdate}
|
||||
>
|
||||
<GlobalTimeProvider {...parentProps}>
|
||||
<GlobalTimeProvider {...childProps}>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('GlobalTimeProvider', () => {
|
||||
describe('name prop', () => {
|
||||
it('should pass name to store when provided', () => {
|
||||
@@ -82,138 +112,73 @@ describe('GlobalTimeProvider', () => {
|
||||
|
||||
describe('inheritGlobalTime', () => {
|
||||
it('should inherit time from parent store when inheritGlobalTime is true', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should inherit '6h' from parent provider
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should use initialTime when inheritGlobalTime is false', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: false, initialTime: '15m' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should use its own initialTime, not parent's
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="?relativeTime=1h">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use inherited time when URL params are empty', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{ searchParams: '' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// No URL params, should keep inherited value
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should prefer custom time URL params over inheritGlobalTime', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
|
||||
>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: NestedWrapper,
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// URL custom time params should override inherited time
|
||||
@@ -690,4 +655,151 @@ describe('GlobalTimeProvider', () => {
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToParentTime', () => {
|
||||
it('should return false when no parent store exists', () => {
|
||||
const wrapper = createWrapper({ initialTime: '1h' });
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.resetToParentTime()).toBe(false);
|
||||
expect(result.current.parentStore).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have parentStore when inheritGlobalTime is true', () => {
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.parentStore).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reset to parent time when called', () => {
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
const initialMinMax = result.current.lastComputedMinMax;
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
const changedMinMax = result.current.lastComputedMinMax;
|
||||
expect(changedMinMax.maxTime - changedMinMax.minTime).toBeLessThan(
|
||||
initialMinMax.maxTime - initialMinMax.minTime,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
const success = result.current.resetToParentTime();
|
||||
expect(success).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
const resetMinMax = result.current.lastComputedMinMax;
|
||||
expect(resetMinMax.maxTime - resetMinMax.minTime).toBeGreaterThan(
|
||||
changedMinMax.maxTime - changedMinMax.minTime,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set shouldClearUrlParams flag when reset', () => {
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetToParentTime();
|
||||
});
|
||||
|
||||
expect(result.current.shouldClearUrlParams).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.clearUrlParamsFlag();
|
||||
});
|
||||
|
||||
expect(result.current.shouldClearUrlParams).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear URL params when resetToParentTime is called with enableUrlParams', async () => {
|
||||
let currentQueryString = 'relativeTime=15m';
|
||||
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{
|
||||
searchParams: currentQueryString,
|
||||
onUrlUpdate: (event): void => {
|
||||
currentQueryString = event.queryString;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetToParentTime();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('relativeTime');
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
});
|
||||
|
||||
it('should clear custom time URL params when resetToParentTime is called', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
|
||||
|
||||
const wrapper = createNestedWrapper(
|
||||
{ initialTime: '6h' },
|
||||
{ inheritGlobalTime: true, enableUrlParams: true },
|
||||
{
|
||||
searchParams: currentQueryString,
|
||||
onUrlUpdate: (event): void => {
|
||||
currentQueryString = event.queryString;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toContain('||_||');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetToParentTime();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('6h');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
|
||||
import {
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStoreApiRef,
|
||||
GlobalTimeStore,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
@@ -18,6 +19,10 @@ import {
|
||||
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
|
||||
export type IGlobalTimeStore = GlobalTimeStore;
|
||||
|
||||
export interface CreateGlobalTimeStoreOptions extends Partial<GlobalTimeState> {
|
||||
parentStore?: GlobalTimeStoreApiRef;
|
||||
}
|
||||
|
||||
function computeIsRefreshEnabled(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
refreshInterval: number,
|
||||
@@ -29,11 +34,12 @@ function computeIsRefreshEnabled(
|
||||
}
|
||||
|
||||
export function createGlobalTimeStore(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
options?: CreateGlobalTimeStoreOptions,
|
||||
): GlobalTimeStoreApi {
|
||||
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = initialState?.refreshInterval ?? 0;
|
||||
const name = initialState?.name;
|
||||
const selectedTime = options?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = options?.refreshInterval ?? 0;
|
||||
const name = options?.name;
|
||||
const parentStore = options?.parentStore;
|
||||
|
||||
return createStore<GlobalTimeStore>((set, get) => ({
|
||||
name,
|
||||
@@ -42,6 +48,8 @@ export function createGlobalTimeStore(
|
||||
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
|
||||
lastRefreshTimestamp: 0,
|
||||
lastComputedMinMax: safeParseSelectedTime(selectedTime),
|
||||
parentStore,
|
||||
shouldClearUrlParams: false,
|
||||
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
@@ -130,6 +138,29 @@ export function createGlobalTimeStore(
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
},
|
||||
|
||||
resetToParentTime: (): boolean => {
|
||||
const state = get();
|
||||
if (!state.parentStore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentSelectedTime = state.parentStore.getState().selectedTime;
|
||||
const computedMinMax = parseSelectedTime(parentSelectedTime);
|
||||
|
||||
set({
|
||||
selectedTime: parentSelectedTime,
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
shouldClearUrlParams: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
clearUrlParamsFlag: (): void => {
|
||||
set({ shouldClearUrlParams: false });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
|
||||
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
|
||||
* | `resetToParentTime()` | Reset to parent store's time (only if `inheritGlobalTime` was used). Clears URL params. Returns `true` on success |
|
||||
*
|
||||
* ### Utilities
|
||||
*
|
||||
@@ -235,7 +236,7 @@
|
||||
* | Option | Type | Description |
|
||||
* |--------|------|-------------|
|
||||
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
|
||||
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
|
||||
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value. Enables `resetToParentTime()` |
|
||||
* | `initialTime` | `string` | Initial time if not inheriting |
|
||||
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
|
||||
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
|
||||
@@ -341,7 +342,39 @@
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 3: Nested Contexts
|
||||
* ### Example 3: Reset to Parent Time
|
||||
*
|
||||
* When using `inheritGlobalTime`, you can reset the child store back to the parent's time:
|
||||
*
|
||||
* ```tsx
|
||||
* function DrawerHeader({ onClose }) {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const resetToParentTime = useGlobalTime((s) => s.resetToParentTime);
|
||||
* const parentStore = useGlobalTime((s) => s.parentStore);
|
||||
*
|
||||
* const handleReset = () => {
|
||||
* // Returns true if reset succeeded, false if no parent store
|
||||
* const success = resetToParentTime();
|
||||
* if (success) {
|
||||
* // URL params are automatically cleared
|
||||
* console.log('Reset to parent time');
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <DateTimeSelectionV3 />
|
||||
* {parentStore && (
|
||||
* <Button onClick={handleReset}>
|
||||
* Reset to Global Time
|
||||
* </Button>
|
||||
* )}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 4: Nested Contexts
|
||||
*
|
||||
* Contexts can be nested - each level creates isolation:
|
||||
*
|
||||
@@ -379,9 +412,10 @@
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 4: URL Sync for Shareable Links
|
||||
* ### Example 5: URL Sync for Shareable Links
|
||||
*
|
||||
* Persist time selection to URL for shareable links:
|
||||
* Persist time selection to URL for shareable links. When using `resetToParentTime()`,
|
||||
* URL params are automatically cleared:
|
||||
*
|
||||
* ```tsx
|
||||
* function TracesExplorer() {
|
||||
@@ -400,7 +434,7 @@
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 5: localStorage Persistence
|
||||
* ### Example 6: localStorage Persistence
|
||||
*
|
||||
* Remember user's last selected time across sessions:
|
||||
*
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { StoreApi } from 'zustand';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
|
||||
export type CustomTimeRangeSeparator = '||_||';
|
||||
export type CustomTimeRange = `${number}${CustomTimeRangeSeparator}${number}`;
|
||||
export type GlobalTimeSelectedTime = Time | CustomTimeRange;
|
||||
|
||||
// Forward declaration to avoid circular dependency
|
||||
export type GlobalTimeStoreApiRef = StoreApi<GlobalTimeStore>;
|
||||
|
||||
export interface IGlobalTimeStoreState {
|
||||
/**
|
||||
* The selected time range, can be:
|
||||
@@ -88,6 +92,16 @@ export interface GlobalTimeState {
|
||||
isRefreshEnabled: boolean;
|
||||
lastRefreshTimestamp: number;
|
||||
lastComputedMinMax: ParsedTimeRange;
|
||||
/**
|
||||
* Reference to parent store when inheritGlobalTime was used.
|
||||
* Enables resetToParentTime() functionality.
|
||||
*/
|
||||
parentStore?: GlobalTimeStoreApiRef;
|
||||
/**
|
||||
* Flag indicating URL params should be cleared (not synced).
|
||||
* Set by resetToParentTime(), consumed by useUrlSync.
|
||||
*/
|
||||
shouldClearUrlParams: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalTimeActions {
|
||||
@@ -118,6 +132,16 @@ export interface GlobalTimeActions {
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
) => unknown[];
|
||||
/**
|
||||
* Reset to parent store's time. Only works if inheritGlobalTime was used.
|
||||
* Clears URL params and sets selectedTime to parent's value.
|
||||
* @returns true if reset succeeded, false if no parent store
|
||||
*/
|
||||
resetToParentTime: () => boolean;
|
||||
/**
|
||||
* Clear the shouldClearUrlParams flag after URL sync has processed it.
|
||||
*/
|
||||
clearUrlParamsFlag: () => void;
|
||||
}
|
||||
|
||||
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;
|
||||
|
||||
@@ -91,6 +91,17 @@ export function useUrlSync(
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.shouldClearUrlParams) {
|
||||
previousSelectedTime = state.selectedTime;
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
store.getState().clearUrlParamsFlag();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { ConfigProvider, SelectProps } from 'antd';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useContext } from 'react';
|
||||
|
||||
export const popupContainer: SelectProps['getPopupContainer'] = (
|
||||
trigger,
|
||||
): HTMLElement => trigger.parentNode;
|
||||
|
||||
/**
|
||||
* Popup container for query-builder Selects. Prefers a container supplied by an
|
||||
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
|
||||
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
|
||||
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
|
||||
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
|
||||
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
|
||||
*/
|
||||
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
|
||||
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
|
||||
return getPopupContainer ?? popupContainer;
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ import (
|
||||
|
||||
var (
|
||||
ErrCodeInvalidGlobalConfig = errors.MustNewCode("invalid_global_config")
|
||||
ErrCodeOriginNotAllowed = errors.MustNewCode("origin_not_allowed")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ExternalURL *url.URL `mapstructure:"external_url"`
|
||||
IngestionURL *url.URL `mapstructure:"ingestion_url"`
|
||||
MCPURL *url.URL `mapstructure:"mcp_url"`
|
||||
AIAssistantURL *url.URL `mapstructure:"ai_assistant_url"`
|
||||
ExternalURL *url.URL `mapstructure:"external_url"`
|
||||
AllowedOrigins []*url.URL `mapstructure:"allowed_origins"`
|
||||
IngestionURL *url.URL `mapstructure:"ingestion_url"`
|
||||
MCPURL *url.URL `mapstructure:"mcp_url"`
|
||||
AIAssistantURL *url.URL `mapstructure:"ai_assistant_url"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
@@ -49,9 +51,33 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if origin == nil || origin.Scheme == "" || origin.Host == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::allowed_origins entries must be of the form scheme://host[:port], got %q", origin)
|
||||
}
|
||||
|
||||
if origin.Path != "" && origin.Path != "/" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::allowed_origins entries must not contain a path, got %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) IsOriginAllowed(u *url.URL) bool {
|
||||
if len(c.AllowedOrigins) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if strings.EqualFold(origin.Scheme, u.Scheme) && strings.EqualFold(origin.Host, u.Host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Config) ExternalPath() string {
|
||||
if c.ExternalURL == nil || c.ExternalURL.Path == "" || c.ExternalURL.Path == "/" {
|
||||
return ""
|
||||
|
||||
@@ -123,6 +123,26 @@ func TestValidate(t *testing.T) {
|
||||
config: Config{ExternalURL: &url.URL{Path: "signoz"}},
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "ValidAllowedOrigin",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https", Host: "signoz.example.com"}}},
|
||||
fail: false,
|
||||
},
|
||||
{
|
||||
name: "AllowedOriginWithoutScheme",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Host: "signoz.example.com"}}},
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "AllowedOriginWithoutHost",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https"}}},
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "AllowedOriginWithPath",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https", Host: "signoz.example.com", Path: "/login"}}},
|
||||
fail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -137,3 +157,96 @@ func TestValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOriginAllowedWhenUnconfigured(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
config: Config{},
|
||||
},
|
||||
{
|
||||
name: "ExternalURLDoesNotActivateValidation",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "signoz.example.com"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u, err := url.Parse("https://anything.example.com/login")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, tc.config.IsOriginAllowed(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOriginAllowed(t *testing.T) {
|
||||
config := Config{
|
||||
AllowedOrigins: []*url.URL{
|
||||
{Scheme: "https", Host: "signoz.example.com"},
|
||||
{Scheme: "http", Host: "localhost:3301"},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ConfiguredOrigin",
|
||||
input: "https://signoz.example.com/login",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ConfiguredOriginWithQuery",
|
||||
input: "http://localhost:3301/login?next=/dashboards",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "CaseInsensitiveHost",
|
||||
input: "https://SigNoz.Example.Com/login",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownHost",
|
||||
input: "https://attacker.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SchemeMismatch",
|
||||
input: "http://signoz.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "PortMismatch",
|
||||
input: "https://signoz.example.com:8443/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SuffixConfusion",
|
||||
input: "https://evilsignoz.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "UserInfoConfusion",
|
||||
input: "https://signoz.example.com@attacker.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SchemeRelative",
|
||||
input: "//attacker.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tc.input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, config.IsOriginAllowed(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
@@ -23,26 +24,28 @@ import (
|
||||
)
|
||||
|
||||
type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN
|
||||
userSetter user.Setter
|
||||
userGetter user.Getter
|
||||
authDomain authdomain.Module
|
||||
tokenizer tokenizer.Tokenizer
|
||||
orgGetter organization.Getter
|
||||
authz authz.AuthZ
|
||||
settings factory.ScopedProviderSettings
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN
|
||||
userSetter user.Setter
|
||||
userGetter user.Getter
|
||||
authDomain authdomain.Module
|
||||
tokenizer tokenizer.Tokenizer
|
||||
orgGetter organization.Getter
|
||||
authz authz.AuthZ
|
||||
globalConfig global.Config
|
||||
}
|
||||
|
||||
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
|
||||
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ, globalConfig global.Config) session.Module {
|
||||
return &module{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
|
||||
authNs: authNs,
|
||||
userSetter: userSetter,
|
||||
userGetter: userGetter,
|
||||
authDomain: authDomain,
|
||||
tokenizer: tokenizer,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
|
||||
authNs: authNs,
|
||||
userSetter: userSetter,
|
||||
userGetter: userGetter,
|
||||
authDomain: authDomain,
|
||||
tokenizer: tokenizer,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
globalConfig: globalConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +143,10 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
return "", err
|
||||
}
|
||||
|
||||
if callbackIdentity.State.URL.Host != "" && !module.globalConfig.IsOriginAllowed(callbackIdentity.State.URL) {
|
||||
return "", errors.Newf(errors.TypeForbidden, global.ErrCodeOriginNotAllowed, "state redirect %q is not an allowed origin", callbackIdentity.State.URL.String())
|
||||
}
|
||||
|
||||
authDomain, err := module.authDomain.GetByOrgIDAndID(ctx, callbackIdentity.OrgID, callbackIdentity.State.DomainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -217,6 +224,10 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !module.globalConfig.IsOriginAllowed(siteURL) {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, global.ErrCodeOriginNotAllowed, "ref %q is not an allowed origin", siteURL.String())
|
||||
}
|
||||
|
||||
loginURL, err := provider.LoginURL(ctx, siteURL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,7 +3,6 @@ package querybuilder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,38 +13,8 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"service.name": {},
|
||||
}
|
||||
|
||||
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
|
||||
|
||||
func EscapeTelemetryValue(value string) string {
|
||||
var escaped strings.Builder
|
||||
for _, character := range []byte(value) {
|
||||
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
|
||||
escaped.WriteByte(character)
|
||||
continue
|
||||
}
|
||||
escaped.WriteString(fmt.Sprintf("%%%02X", character))
|
||||
}
|
||||
|
||||
return escaped.String()
|
||||
}
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := []string{id}
|
||||
segments := strings.Split(id, "/")
|
||||
for level := len(segments) - 1; level >= 1; level-- {
|
||||
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
|
||||
if value == id {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if id != coretypes.WildCardSelectorString {
|
||||
values = append(values, coretypes.WildCardSelectorString)
|
||||
}
|
||||
values := telemetrytypes.NewTelemetryGrantSelectors(id)
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -187,14 +156,14 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := canonicalTelemetryGrantKey(condition.Key)
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if condition.Operator == "=" || condition.Operator == "IN" {
|
||||
for _, value := range condition.Values {
|
||||
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
|
||||
ids = append(ids, queryType+"/"+key+"/"+value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,16 +174,3 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -22,33 +21,33 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
expected []coretypes.ResourceWithID
|
||||
}{
|
||||
{
|
||||
name: "top level service equality",
|
||||
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
|
||||
name: "top level key equality",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed service key",
|
||||
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
|
||||
name: "resource prefixed key",
|
||||
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms each require a grant",
|
||||
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -59,38 +58,38 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
|
||||
name: "key atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated service atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT service.name = 'a'"),
|
||||
name: "negated key atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service inequality does not qualify",
|
||||
body: builderQueryBody("logs", "service.name != 'a'"),
|
||||
name: "key inequality does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsafe value bytes are escaped",
|
||||
body: builderQueryBody("logs", "service.name = 'check out/2'"),
|
||||
name: "value with spaces and slashes stays plaintext in the id",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit source maps to audit logs resource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,23 +116,23 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "trace operator rides on its referenced queries",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable substitution qualifies",
|
||||
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
|
||||
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -151,7 +150,7 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"compositeQuery":{"queries":[]}}`,
|
||||
`{}`,
|
||||
builderQueryBody("logs", "service.name = "),
|
||||
builderQueryBody("logs", "signoz.workspace.key.id = "),
|
||||
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
|
||||
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
|
||||
}
|
||||
@@ -175,10 +174,10 @@ func TestTelemetrySelector(t *testing.T) {
|
||||
return values
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
|
||||
|
||||
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
|
||||
assert.Equal(t, []string{"*"}, selectorValues("*"))
|
||||
// a value containing "/" stays one logical segment (SplitN 3).
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func NewModules(
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -106,10 +107,6 @@ func NewGettableTransaction(results []*TransactionWithAuthorization) []*Gettable
|
||||
return gettableTransactions
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, deletions TransactionGroups) {
|
||||
return desired.subtract(groups), groups.subtract(desired)
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
@@ -168,51 +165,6 @@ func (transaction *Transaction) TransactionKey() string {
|
||||
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) subtract(other TransactionGroups) TransactionGroups {
|
||||
otherSelectors := other.selectorSet()
|
||||
|
||||
order := make([]string, 0)
|
||||
grouped := make(map[string]*TransactionGroup)
|
||||
for _, group := range groups {
|
||||
for _, selector := range group.ObjectGroup.Selectors {
|
||||
if _, ok := otherSelectors[group.selectorKey(selector)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
groupKey := group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String()
|
||||
out, ok := grouped[groupKey]
|
||||
if !ok {
|
||||
out = &TransactionGroup{Relation: group.Relation, ObjectGroup: coretypes.ObjectGroup{Resource: group.ObjectGroup.Resource, Selectors: make([]coretypes.Selector, 0)}}
|
||||
grouped[groupKey] = out
|
||||
order = append(order, groupKey)
|
||||
}
|
||||
out.ObjectGroup.Selectors = append(out.ObjectGroup.Selectors, selector)
|
||||
}
|
||||
}
|
||||
|
||||
result := make(TransactionGroups, 0, len(order))
|
||||
for _, key := range order {
|
||||
result = append(result, grouped[key])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) selectorSet() map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, group := range groups {
|
||||
for _, selector := range group.ObjectGroup.Selectors {
|
||||
set[group.selectorKey(selector)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
|
||||
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
|
||||
}
|
||||
|
||||
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
|
||||
verb, err := coretypes.NewVerb(raw.Relation)
|
||||
if err != nil {
|
||||
@@ -236,6 +188,13 @@ func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup,
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
|
||||
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
|
||||
if resourceType.Equals(coretypes.TypeTelemetryResource) {
|
||||
rawSelector, err = telemetrytypes.NewTelemetryGrantSelector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
selector, err := resourceType.Selector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
|
||||
@@ -3,6 +3,7 @@ package authtypes
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
@@ -68,6 +69,38 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func DiffTuples(existing, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
|
||||
key := func(tuple *openfgav1.TupleKey) string {
|
||||
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
|
||||
}
|
||||
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, tuple := range existing {
|
||||
existingSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
desiredSet := make(map[string]struct{}, len(desired))
|
||||
for _, tuple := range desired {
|
||||
desiredSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
additions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range desired {
|
||||
if _, ok := existingSet[key(tuple)]; !ok {
|
||||
additions = append(additions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
deletions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range existing {
|
||||
if _, ok := desiredSet[key(tuple)]; !ok {
|
||||
deletions = append(deletions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
return additions, deletions
|
||||
}
|
||||
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
|
||||
objectsByRelation := make(map[string][]*coretypes.Object)
|
||||
|
||||
@@ -109,17 +142,29 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectorStrings, err := newCheckSelectors(txn.Object.Resource.Type, txn.Object.Selector)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(selectorStrings))
|
||||
for _, selectorString := range selectorStrings {
|
||||
selector, err := txn.Object.Resource.Type.Selector(selectorString)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
selectors = append(selectors, selector)
|
||||
}
|
||||
|
||||
txnID := txn.ID.StringValue()
|
||||
|
||||
txnTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{txn.Object.Selector}, orgID)
|
||||
tuples[txnID] = txnTuples[0]
|
||||
|
||||
if txn.Object.Selector.String() != coretypes.WildCardSelectorString {
|
||||
wildcardSelector := txn.Object.Resource.Type.MustSelector(coretypes.WildCardSelectorString)
|
||||
wildcardTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{wildcardSelector}, orgID)
|
||||
for index, tuple := range NewTuples(resource, subject, txn.Relation, selectors, orgID) {
|
||||
if index == 0 {
|
||||
tuples[txnID] = tuple
|
||||
continue
|
||||
}
|
||||
|
||||
correlationID := valuer.GenerateUUID().StringValue()
|
||||
tuples[correlationID] = wildcardTuples[0]
|
||||
tuples[correlationID] = tuple
|
||||
correlations[txnID] = append(correlations[txnID], correlationID)
|
||||
}
|
||||
}
|
||||
@@ -214,3 +259,21 @@ func NewTransactionWithAuthorizationFromBatchResults(
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func newCheckSelectors(resourceType coretypes.Type, selector coretypes.Selector) ([]string, error) {
|
||||
if resourceType.Equals(coretypes.TypeTelemetryResource) {
|
||||
canonical, err := telemetrytypes.NewTelemetryGrantSelector(selector.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return telemetrytypes.NewTelemetryGrantSelectors(canonical), nil
|
||||
}
|
||||
|
||||
selectorStrings := []string{selector.String()}
|
||||
if selector.String() != coretypes.WildCardSelectorString {
|
||||
selectorStrings = append(selectorStrings, coretypes.WildCardSelectorString)
|
||||
}
|
||||
|
||||
return selectorStrings, nil
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@ var (
|
||||
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
|
||||
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []Verb{VerbRead}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^.{1,512}$`), []Verb{VerbRead}}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -26,7 +29,18 @@ func (resourceTelemetryResource *resourceTelemetryResource) Prefix(orgID valuer.
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Object(orgID valuer.UUID, selector string) string {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
if selector == WildCardSelectorString {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
}
|
||||
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + telemetrySelectorHash(selector)
|
||||
}
|
||||
|
||||
// Must stay stable: grant-time and check-time tuple objects both hash the selector
|
||||
// here, so changing this invalidates every stored telemetry grant tuple.
|
||||
func telemetrySelectorHash(selector string) string {
|
||||
sum := sha256.Sum256([]byte(selector))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Scope(verb Verb) string {
|
||||
|
||||
110
pkg/types/telemetrytypes/selector.go
Normal file
110
pkg/types/telemetrytypes/selector.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const wildcardSelector = "*"
|
||||
|
||||
var telemetryGrantQueryTypes = map[string]bool{
|
||||
"builder_query": true,
|
||||
"builder_sub_query": true,
|
||||
"promql": false,
|
||||
"clickhouse_sql": false,
|
||||
}
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"signoz.workspace.key.id": {},
|
||||
}
|
||||
|
||||
func NewTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != FieldContextUnspecified && fieldKey.FieldContext != FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelector(input string) (string, error) {
|
||||
if input == wildcardSelector {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, "/", 3)
|
||||
|
||||
keyScoped, ok := telemetryGrantQueryTypes[parts[0]]
|
||||
if !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must start with a supported query type or be %q", input, wildcardSelector)
|
||||
}
|
||||
queryType := parts[0]
|
||||
|
||||
if len(parts) < 3 {
|
||||
if len(parts) == 2 && parts[1] != wildcardSelector {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must be <query_type>, <query_type>/*, <query_type>/<key>/* or <query_type>/<key>/<value>", input)
|
||||
}
|
||||
return queryType + "/" + wildcardSelector, nil
|
||||
}
|
||||
|
||||
if !keyScoped {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q is invalid: query type %q supports only %q or %q", input, queryType, queryType, queryType+"/"+wildcardSelector)
|
||||
}
|
||||
|
||||
key, ok := NewTelemetryGrantKey(parts[1])
|
||||
if !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a supported key: %s", input, strings.Join(telemetryGrantKeyNames(), ", "))
|
||||
}
|
||||
|
||||
value := parts[2]
|
||||
if value == wildcardSelector {
|
||||
return queryType + "/" + key + "/" + wildcardSelector, nil
|
||||
}
|
||||
if value == "" || strings.HasPrefix(value, "$") {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a concrete non-empty value", input)
|
||||
}
|
||||
|
||||
return queryType + "/" + key + "/" + value, nil
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelectors(selector string) []string {
|
||||
if selector == wildcardSelector {
|
||||
return []string{wildcardSelector}
|
||||
}
|
||||
|
||||
parts := strings.SplitN(selector, "/", 3)
|
||||
queryType := parts[0]
|
||||
|
||||
if len(parts) < 3 {
|
||||
return []string{queryType + "/" + wildcardSelector, wildcardSelector}
|
||||
}
|
||||
|
||||
key, value := parts[1], parts[2]
|
||||
if value == wildcardSelector {
|
||||
return []string{
|
||||
queryType + "/" + key + "/" + wildcardSelector,
|
||||
queryType + "/" + wildcardSelector,
|
||||
wildcardSelector,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
queryType + "/" + key + "/" + value,
|
||||
queryType + "/" + key + "/" + wildcardSelector,
|
||||
queryType + "/" + wildcardSelector,
|
||||
wildcardSelector,
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryGrantKeyNames() []string {
|
||||
names := make([]string, 0, len(telemetryGrantKeys))
|
||||
for name := range telemetryGrantKeys {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
78
pkg/types/telemetrytypes/selector_test.go
Normal file
78
pkg/types/telemetrytypes/selector_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTelemetryGrantSelector(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"*": "*",
|
||||
"builder_query": "builder_query/*",
|
||||
"builder_query/*": "builder_query/*",
|
||||
"promql": "promql/*",
|
||||
"clickhouse_sql": "clickhouse_sql/*",
|
||||
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
|
||||
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
|
||||
}
|
||||
for input, expected := range valid {
|
||||
canonical, err := NewTelemetryGrantSelector(input)
|
||||
require.NoError(t, err, "input %q", input)
|
||||
assert.Equal(t, expected, canonical, "input %q", input)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"key-a",
|
||||
"signoz.workspace.key.id = 'key-a'",
|
||||
"builder_trace_operator/signoz.workspace.key.id/key-a",
|
||||
"builder_query/service.name/frontend",
|
||||
"builder_query/signoz.workspace.key.id/",
|
||||
"builder_query/signoz.workspace.key.id/$svc",
|
||||
"*/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id",
|
||||
"clickhouse_sql/signoz.workspace.key.id/key-a",
|
||||
"clickhouse_sql/signoz.workspace.key.id/*",
|
||||
"promql/signoz.workspace.key.id/key-a",
|
||||
}
|
||||
for _, input := range invalid {
|
||||
_, err := NewTelemetryGrantSelector(input)
|
||||
assert.Error(t, err, "input %q", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelemetryGrantKey(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
}
|
||||
for keyText, expected := range valid {
|
||||
key, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.True(t, ok, keyText)
|
||||
assert.Equal(t, expected, key, keyText)
|
||||
}
|
||||
|
||||
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
|
||||
_, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.False(t, ok, keyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelemetryGrantSelectors(t *testing.T) {
|
||||
ladders := map[string][]string{
|
||||
"*": {"*"},
|
||||
"builder_query/*": {"builder_query/*", "*"},
|
||||
"promql/*": {"promql/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/*": {"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/a": {"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/a/b": {"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
}
|
||||
for selector, expected := range ladders {
|
||||
assert.Equal(t, expected, NewTelemetryGrantSelectors(selector), "selector %q", selector)
|
||||
}
|
||||
}
|
||||
7
tests/fixtures/clickhouse.py
vendored
7
tests/fixtures/clickhouse.py
vendored
@@ -203,9 +203,9 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "clickhouse",
|
||||
) -> types.TestContainerClickhouse:
|
||||
coordinator = next(iter(keeper.container_configs.values()))
|
||||
|
||||
def create() -> types.TestContainerClickhouse:
|
||||
# Lazy: the keeper fixture is empty under --teardown (never created).
|
||||
coordinator = next(iter(keeper.container_configs.values()))
|
||||
clickhouse_version = request.config.getoption("--clickhouse-version")
|
||||
|
||||
container = ClickHouseContainer(
|
||||
@@ -381,9 +381,10 @@ def create_clickhouse_cluster( # pylint: disable=too-many-arguments,too-many-po
|
||||
migration goes through. Per-node containers are exposed via `nodes` so
|
||||
tests can assert shard-local state.
|
||||
"""
|
||||
coordinator = next(iter(keeper.container_configs.values()))
|
||||
|
||||
def create() -> types.TestContainerClickhouse:
|
||||
# Lazy: the keeper fixture is empty under --teardown (never created).
|
||||
coordinator = next(iter(keeper.container_configs.values()))
|
||||
clickhouse_version = request.config.getoption("--clickhouse-version")
|
||||
|
||||
# Unique aliases per creation: docker allows duplicate network aliases
|
||||
|
||||
29
tests/fixtures/network.py
vendored
29
tests/fixtures/network.py
vendored
@@ -1,3 +1,5 @@
|
||||
import time
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
@@ -23,12 +25,37 @@ def network(request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> type
|
||||
def delete(nw: types.Network):
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.networks.get(network_id=nw.id).remove()
|
||||
network = client.networks.get(network_id=nw.id)
|
||||
except docker.errors.NotFound:
|
||||
logger.info(
|
||||
"Skipping removal of Network, Network(%s) not found. Maybe it was manually removed?",
|
||||
{"name": nw.name, "id": nw.id},
|
||||
)
|
||||
return
|
||||
|
||||
# Docker detaches endpoints asynchronously, so the network can briefly
|
||||
# report "has active endpoints" after its containers are gone. Retry,
|
||||
# force-disconnecting any stragglers.
|
||||
last_err: docker.errors.APIError | None = None
|
||||
for _ in range(10):
|
||||
try:
|
||||
network.remove()
|
||||
return
|
||||
except docker.errors.NotFound:
|
||||
return
|
||||
except docker.errors.APIError as err:
|
||||
if "has active endpoints" not in str(err):
|
||||
raise
|
||||
last_err = err
|
||||
network.reload()
|
||||
for container_id in network.attrs.get("Containers") or {}:
|
||||
try:
|
||||
network.disconnect(container_id, force=True)
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
raise last_err
|
||||
|
||||
def restore(existing: dict) -> types.Network:
|
||||
client = docker.from_env()
|
||||
|
||||
@@ -19,8 +19,8 @@ from fixtures.querier import build_raw_query, get_column_data_from_response, mak
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
scoped_role = "telemetry-scope-svc-a"
|
||||
scoped_email = "scope-svc-a@telemetry.test"
|
||||
scoped_role = "telemetry-scope-key-a"
|
||||
scoped_email = "scope-key-a@telemetry.test"
|
||||
|
||||
|
||||
def test_setup(
|
||||
@@ -37,8 +37,8 @@ def test_setup(
|
||||
admin_token,
|
||||
scoped_role,
|
||||
[
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/service-a"]),
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"]),
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key-a"]),
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"]),
|
||||
],
|
||||
)
|
||||
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
|
||||
@@ -48,10 +48,13 @@ def test_setup(
|
||||
@pytest.mark.parametrize(
|
||||
"selector",
|
||||
[
|
||||
"service.name = 'service-a'", # expression form, not the wire form
|
||||
"builder_query/service.name/check out", # raw space
|
||||
"builder_query/service.name/'quoted'", # quote
|
||||
"builder_query/service.name/a/b/c", # too deep
|
||||
"signoz.workspace.key.id = 'key-a'", # expression form, not the wire form
|
||||
"unknown_query_type/signoz.workspace.key.id/key-a", # unsupported query type
|
||||
"builder_query/service.name/frontend", # service.name is not a supported grant key
|
||||
"*/signoz.workspace.key.id/key-a", # non-prefix wildcard
|
||||
"builder_query/signoz.workspace.key.id/", # empty value
|
||||
"builder_query/signoz.workspace.key.id", # missing value, not a wildcard
|
||||
"clickhouse_sql/signoz.workspace.key.id/key-a", # clickhouse_sql does not support key-scoped selectors
|
||||
],
|
||||
)
|
||||
def test_invalid_telemetry_selector_rejected(
|
||||
@@ -75,10 +78,10 @@ def test_invalid_telemetry_selector_rejected(
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
"service.name = 'service-a'",
|
||||
"service.name IN ('service-a')",
|
||||
"resource.service.name = 'service-a'",
|
||||
"service.name = 'service-a' AND severity_text = 'ERROR'",
|
||||
"signoz.workspace.key.id = 'key-a'",
|
||||
"signoz.workspace.key.id IN ('key-a')",
|
||||
"resource.signoz.workspace.key.id = 'key-a'",
|
||||
"signoz.workspace.key.id = 'key-a' AND severity_text = 'ERROR'",
|
||||
],
|
||||
)
|
||||
def test_allowed(
|
||||
@@ -88,9 +91,9 @@ def test_allowed(
|
||||
expression: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
# Seed a service-a log so the resource-attribute key resolves; without any
|
||||
# Seed a key-a log so the resource-attribute key resolves; without any
|
||||
# ingested data the querier rejects the filter with "key not found".
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0")])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
@@ -107,15 +110,15 @@ def test_allowed(
|
||||
"expression",
|
||||
[
|
||||
None, # no filter
|
||||
"service.name = 'service-b'",
|
||||
"service.name IN ('service-a', 'service-b')",
|
||||
"service.name = 'service-a' OR severity_text = 'ERROR'",
|
||||
"NOT service.name = 'service-a'",
|
||||
"service.name != 'service-b'",
|
||||
# Same result set as IN ('service-a','service-b'), but the OR spelling is not
|
||||
"signoz.workspace.key.id = 'key-b'",
|
||||
"signoz.workspace.key.id IN ('key-a', 'key-b')",
|
||||
"signoz.workspace.key.id = 'key-a' OR severity_text = 'ERROR'",
|
||||
"NOT signoz.workspace.key.id = 'key-a'",
|
||||
"signoz.workspace.key.id != 'key-b'",
|
||||
# Same result set as IN ('key-a','key-b'), but the OR spelling is not
|
||||
# yet recognized as a bounded set, so it is denied today. This flips to
|
||||
# allowed-with-both-grants once the where-clause bound evaluation lands.
|
||||
"service.name = 'service-a' OR service.name = 'service-b'",
|
||||
"signoz.workspace.key.id = 'key-a' OR signoz.workspace.key.id = 'key-b'",
|
||||
],
|
||||
)
|
||||
def test_denied(
|
||||
@@ -146,11 +149,11 @@ def test_denied_message_names_resource(
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-b'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert "builder_query/service.name/service-b" in response.text
|
||||
assert "builder_query/signoz.workspace.key.id/key-b" in response.text
|
||||
|
||||
|
||||
def test_variables_resolve_into_gate(
|
||||
@@ -159,7 +162,7 @@ def test_variables_resolve_into_gate(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0")])
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(scoped_email, user_password)
|
||||
|
||||
@@ -168,9 +171,9 @@ def test_variables_resolve_into_gate(
|
||||
token,
|
||||
start,
|
||||
end,
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = $key")],
|
||||
request_type="raw",
|
||||
variables={"svc": {"value": "service-a"}},
|
||||
variables={"key": {"value": "key-a"}},
|
||||
)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
@@ -179,9 +182,9 @@ def test_variables_resolve_into_gate(
|
||||
token,
|
||||
start,
|
||||
end,
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = $key")],
|
||||
request_type="raw",
|
||||
variables={"svc": {"value": "service-b"}},
|
||||
variables={"key": {"value": "key-b"}},
|
||||
)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
|
||||
@@ -192,17 +195,17 @@ def test_returns_only_scoped_rows(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-a"}, body=f"service-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-b"}, body=f"service-b-{i}") for i in range(3)])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"signoz.workspace.key.id": "key-a"}, body=f"key-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"signoz.workspace.key.id": "key-b"}, body=f"key-b-{i}") for i in range(3)])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-a'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bodies = get_column_data_from_response(response.json(), "body")
|
||||
assert bodies, "expected rows for service-a"
|
||||
assert all(body.startswith("service-a") for body in bodies), bodies
|
||||
assert bodies, "expected rows for key-a"
|
||||
assert all(body.startswith("key-a") for body in bodies), bodies
|
||||
|
||||
@@ -9,8 +9,8 @@ from fixtures.querier import build_raw_query, get_column_data_from_response, mak
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
any_service_role = "telemetry-scope-any-service"
|
||||
any_service_email = "scope-any-service@telemetry.test"
|
||||
any_key_role = "telemetry-scope-any-key"
|
||||
any_key_email = "scope-any-key@telemetry.test"
|
||||
builder_all_role = "telemetry-scope-builder-all"
|
||||
builder_all_email = "scope-builder-all@telemetry.test"
|
||||
|
||||
@@ -23,16 +23,16 @@ def test_setup(
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
create_role(admin_token, any_service_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/*"])])
|
||||
any_user = create_active_user(signoz, admin_token, email=any_service_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_service_role)
|
||||
create_role(admin_token, any_key_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
|
||||
any_user = create_active_user(signoz, admin_token, email=any_key_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_key_role)
|
||||
|
||||
create_role(admin_token, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
|
||||
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, all_user, "signoz-viewer", builder_all_role)
|
||||
|
||||
|
||||
def test_service_wildcard_allows_any_single_service(
|
||||
def test_key_wildcard_allows_any_single_key(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
@@ -40,28 +40,28 @@ def test_service_wildcard_allows_any_single_service(
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-b"}, body="key-b-0"),
|
||||
]
|
||||
)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(any_service_email, user_password)
|
||||
token = get_token(any_key_email, user_password)
|
||||
|
||||
service_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")], request_type="raw")
|
||||
assert service_a.status_code == HTTPStatus.OK, service_a.text
|
||||
key_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-a'")], request_type="raw")
|
||||
assert key_a.status_code == HTTPStatus.OK, key_a.text
|
||||
|
||||
service_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")], request_type="raw")
|
||||
assert service_b.status_code == HTTPStatus.OK, service_b.text
|
||||
key_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-b'")], request_type="raw")
|
||||
assert key_b.status_code == HTTPStatus.OK, key_b.text
|
||||
|
||||
|
||||
def test_service_wildcard_denies_unfiltered(
|
||||
def test_key_wildcard_denies_unfiltered(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(any_service_email, user_password),
|
||||
get_token(any_key_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50)],
|
||||
@@ -86,7 +86,7 @@ def test_builder_wildcard_allows_unfiltered(
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_admin_allows_unfiltered_across_services(
|
||||
def test_admin_allows_unfiltered_across_keys(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
@@ -94,8 +94,8 @@ def test_admin_allows_unfiltered_across_services(
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-b"}, body="key-b-0"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -109,4 +109,4 @@ def test_admin_allows_unfiltered_across_services(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bodies = get_column_data_from_response(response.json(), "body")
|
||||
assert any(body.startswith("service-b") for body in bodies), bodies
|
||||
assert any(body.startswith("key-b") for body in bodies), bodies
|
||||
|
||||
@@ -10,8 +10,8 @@ from fixtures.role import transaction_group
|
||||
user_password = "password123Z$"
|
||||
chsql_role = "telemetry-scope-chsql"
|
||||
chsql_email = "scope-chsql@telemetry.test"
|
||||
svc_a_role = "telemetry-qt-svc-a"
|
||||
svc_a_email = "qt-svc-a@telemetry.test"
|
||||
key_a_role = "telemetry-qt-key-a"
|
||||
key_a_email = "qt-key-a@telemetry.test"
|
||||
viewer_email = "qt-managed-viewer@telemetry.test"
|
||||
|
||||
clickhouse_query = [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT toFloat64(1.5) AS `__result_0`", "disabled": False}}]
|
||||
@@ -41,9 +41,9 @@ def test_setup(
|
||||
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, chsql_user, "signoz-viewer", chsql_role)
|
||||
|
||||
create_role(admin_token, svc_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"])])
|
||||
svc_a_user = create_active_user(signoz, admin_token, email=svc_a_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, svc_a_user, "signoz-viewer", svc_a_role)
|
||||
create_role(admin_token, key_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"])])
|
||||
key_a_user = create_active_user(signoz, admin_token, email=key_a_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, key_a_user, "signoz-viewer", key_a_role)
|
||||
|
||||
# A plain managed viewer (signoz-viewer) — for the meter-metrics/audit-logs policy checks.
|
||||
create_active_user(signoz, admin_token, email=viewer_email, role="VIEWER", password=user_password)
|
||||
@@ -59,7 +59,7 @@ def test_clickhouse_sql_requires_chsql_grant(
|
||||
granted = make_query_request(signoz, get_token(chsql_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert granted.status_code == HTTPStatus.OK, granted.text
|
||||
|
||||
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
scoped = make_query_request(signoz, get_token(key_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
|
||||
|
||||
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
@@ -73,8 +73,8 @@ def test_promql_requires_promql_grant(
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
|
||||
# Neither the chsql grant nor a builder-service grant covers promql.
|
||||
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
|
||||
# Neither the chsql grant nor a builder-key grant covers promql.
|
||||
scoped = make_query_request(signoz, get_token(key_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
|
||||
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
|
||||
|
||||
# Admin holds the wildcard; authz passes (the handler may still 2xx/4xx, never 403).
|
||||
@@ -88,19 +88,19 @@ def test_trace_operator_rides_on_referenced_queries(
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(svc_a_email, user_password)
|
||||
token = get_token(key_a_email, user_password)
|
||||
|
||||
def operator_queries(b_service: str) -> list[dict]:
|
||||
def operator_queries(b_key: str) -> list[dict]:
|
||||
return [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"service.name = '{b_service}'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"signoz.workspace.key.id = '{b_key}'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_trace_operator", "spec": {"name": "T1", "expression": "A => B", "returnSpansFrom": "A", "disabled": False}},
|
||||
]
|
||||
|
||||
allowed = make_query_request(signoz, token, start, end, operator_queries("service-a"), request_type=querier.RequestType.RAW)
|
||||
allowed = make_query_request(signoz, token, start, end, operator_queries("key-a"), request_type=querier.RequestType.RAW)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
denied = make_query_request(signoz, token, start, end, operator_queries("service-b"), request_type=querier.RequestType.RAW)
|
||||
denied = make_query_request(signoz, token, start, end, operator_queries("key-b"), request_type=querier.RequestType.RAW)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
|
||||
|
||||
@@ -110,14 +110,14 @@ def test_formula_rides_on_referenced_queries(
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(svc_a_email, user_password)
|
||||
token = get_token(key_a_email, user_password)
|
||||
|
||||
def formula_queries(b_filtered: bool) -> list[dict]:
|
||||
b_spec = {"name": "B", "signal": "traces", "disabled": True, "aggregations": [{"expression": "count()"}]}
|
||||
if b_filtered:
|
||||
b_spec["filter"] = {"expression": "service.name = 'service-a'"}
|
||||
b_spec["filter"] = {"expression": "signoz.workspace.key.id = 'key-a'"}
|
||||
return [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": b_spec},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A/B", "disabled": False}},
|
||||
]
|
||||
|
||||
@@ -11,8 +11,9 @@ from fixtures.role import transaction_group
|
||||
user_password = "password123Z$"
|
||||
spacey_role = "telemetry-scope-spacey"
|
||||
spacey_email = "scope-spacey@telemetry.test"
|
||||
# The service name has a space; its canonical selector escapes it to %20.
|
||||
spacey_service = "check out"
|
||||
# The grant value has a space; it is stored plaintext in the role record and hashed
|
||||
# into the tuple, so a matching query must round-trip the exact value.
|
||||
spacey_value = "key with space"
|
||||
|
||||
|
||||
def test_setup(
|
||||
@@ -22,31 +23,31 @@ def test_setup(
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/check%20out"])])
|
||||
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key with space"])])
|
||||
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", spacey_role)
|
||||
|
||||
|
||||
def test_escaped_value_parity_allows_matching_service(
|
||||
def test_escaped_value_parity_allows_matching_value(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": spacey_service}, body="spacey-0")])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": spacey_value}, body="spacey-0")])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(spacey_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=f"service.name = '{spacey_service}'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=f"signoz.workspace.key.id = '{spacey_value}'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_escaped_value_denies_other_service(
|
||||
def test_escaped_value_denies_other_value(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
@@ -56,7 +57,7 @@ def test_escaped_value_denies_other_service(
|
||||
get_token(spacey_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'checkout'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'keywithspace'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
|
||||
50
tests/integration/tests/querierauthz/05_check_api.py
Normal file
50
tests/integration/tests/querierauthz/05_check_api.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
keywild_role = "telemetry-check-keywild"
|
||||
keywild_email = "check-keywild@telemetry.test"
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(admin_token, keywild_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
|
||||
user_id = create_active_user(signoz, admin_token, email=keywild_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", keywild_role)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "authorized"),
|
||||
[
|
||||
("builder_query/signoz.workspace.key.id/key-a", True), # concrete value resolves up the ladder to the key wildcard grant
|
||||
("builder_query/signoz.workspace.key.id/*", True), # exact grant
|
||||
("builder_query/resource.signoz.workspace.key.id/key-a", True), # resource.signoz.workspace.key.id folds before laddering
|
||||
("promql/*", False), # different query type never reaches the builder_query grant
|
||||
],
|
||||
)
|
||||
def test_check_ladders_to_key_wildcard_grant(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
selector: str,
|
||||
authorized: bool,
|
||||
) -> None:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/authz/check"),
|
||||
json=[{"relation": "read", "object": {"resource": {"type": "telemetryresource", "kind": "logs"}, "selector": selector}}],
|
||||
headers={"Authorization": f"Bearer {get_token(keywild_email, user_password)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"][0]["authorized"] is authorized
|
||||
Reference in New Issue
Block a user