mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-25 09:42:25 +00:00
Compare commits
3 Commits
SIG_3786_h
...
pipelinesv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e54a555470 | ||
|
|
ae55a51d66 | ||
|
|
023e315a22 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
- sqlite
|
||||
clickhouse-version:
|
||||
- 25.5.6
|
||||
- 25.12.5
|
||||
- 25.10.5
|
||||
schema-migrator-version:
|
||||
- v0.142.0
|
||||
postgres-version:
|
||||
|
||||
@@ -82,12 +82,6 @@ exporters:
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
metadataexporter:
|
||||
cache:
|
||||
provider: in_memory
|
||||
dsn: tcp://clickhouse:9000/signoz_metadata
|
||||
enabled: true
|
||||
timeout: 45s
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -99,19 +93,19 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, metadataexporter, signozmeter]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
|
||||
@@ -82,12 +82,6 @@ exporters:
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
metadataexporter:
|
||||
cache:
|
||||
provider: in_memory
|
||||
dsn: tcp://clickhouse:9000/signoz_metadata
|
||||
enabled: true
|
||||
timeout: 45s
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -99,19 +93,19 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces, metadataexporter, signozmeter]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
|
||||
@@ -308,15 +308,3 @@ export const PublicDashboardPage = Loadable(
|
||||
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
|
||||
),
|
||||
);
|
||||
|
||||
export const AlertTypeSelectionPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "Alert Type Selection Page" */ 'pages/AlertTypeSelection'
|
||||
),
|
||||
);
|
||||
|
||||
export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertTypeSelectionPage from 'pages/AlertTypeSelection';
|
||||
import MessagingQueues from 'pages/MessagingQueues';
|
||||
import MeterExplorer from 'pages/MeterExplorer';
|
||||
|
||||
import {
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
AllAlertChannels,
|
||||
AllErrors,
|
||||
ApiMonitoring,
|
||||
@@ -27,8 +29,6 @@ import {
|
||||
LogsExplorer,
|
||||
LogsIndexToFields,
|
||||
LogsSaveViews,
|
||||
MessagingQueuesMainPage,
|
||||
MeterExplorerPage,
|
||||
MetricsExplorer,
|
||||
OldLogsExplorer,
|
||||
Onboarding,
|
||||
@@ -399,28 +399,28 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_KAFKA',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_CELERY_TASK',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
|
||||
exact: true,
|
||||
component: MessagingQueuesMainPage,
|
||||
component: MessagingQueues,
|
||||
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
|
||||
isPrivate: true,
|
||||
},
|
||||
@@ -463,21 +463,21 @@ const routes: AppRoutes[] = [
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
component: MeterExplorer,
|
||||
key: 'METER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER_VIEWS,
|
||||
exact: true,
|
||||
component: MeterExplorerPage,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
@@ -86,13 +86,8 @@ function LogDetailInner({
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking on drawer content, overlays, or portal elements
|
||||
if (
|
||||
target.closest('[data-log-detail-ignore="true"]') ||
|
||||
target.closest('.cm-tooltip-autocomplete') ||
|
||||
target.closest('.drawer-popover') ||
|
||||
target.closest('.query-status-popover')
|
||||
) {
|
||||
// Don't close if clicking on explicitly ignored regions
|
||||
if (target.closest('[data-log-detail-ignore="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -405,11 +400,7 @@ function LogDetailInner({
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(log?.body)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -475,7 +466,6 @@ function LogDetailInner({
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
@@ -491,7 +481,6 @@ function LogDetailInner({
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
|
||||
@@ -27,11 +27,7 @@ function AddToQueryHOC({
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className={cx('addToQueryContainer', fontSize)} onClick={handleQueryAdd}>
|
||||
<Popover
|
||||
overlayClassName="drawer-popover"
|
||||
placement="top"
|
||||
content={popOverContent}
|
||||
>
|
||||
<Popover placement="top" content={popOverContent}>
|
||||
{children}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ function CopyClipboardHOC({
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
overlayClassName="drawer-popover"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
maxWidth: '90rem',
|
||||
maxWidth: '60rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
|
||||
@@ -1328,10 +1328,7 @@ function QuerySearch({
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
>
|
||||
<Tooltip title={getTooltipContent()} placement="left">
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
@@ -32,9 +31,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
(state) => state.dashboardId,
|
||||
);
|
||||
const sortedVariablesArray = useDashboardVariablesSelector(
|
||||
(state) => state.sortedVariablesArray,
|
||||
);
|
||||
@@ -100,28 +96,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
updateUrlVariable(name || id, value);
|
||||
}
|
||||
|
||||
// Synchronously update the external store with the new variable value so that
|
||||
// child variables see the updated parent value when they refetch, rather than
|
||||
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
|
||||
const updatedVariables = { ...dashboardVariables };
|
||||
if (updatedVariables[id]) {
|
||||
updatedVariables[id] = {
|
||||
...updatedVariables[id],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
if (updatedVariables[name]) {
|
||||
updatedVariables[name] = {
|
||||
...updatedVariables[name],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
|
||||
|
||||
setSelectedDashboard((prev) => {
|
||||
if (prev) {
|
||||
const oldVariables = { ...prev?.data.variables };
|
||||
@@ -156,12 +130,10 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Cascade: enqueue query-type descendants for refetching.
|
||||
// Safe to call synchronously now that the store already has the updated value.
|
||||
// Cascade: enqueue query-type descendants for refetching
|
||||
enqueueDescendantsOfVariable(name);
|
||||
},
|
||||
[
|
||||
dashboardId,
|
||||
dashboardVariables,
|
||||
updateLocalStorageDashboardVariables,
|
||||
updateUrlVariable,
|
||||
|
||||
@@ -5,7 +5,7 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import { isArray, isEmpty } from 'lodash-es';
|
||||
import { isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -54,7 +54,7 @@ function QueryVariableInput({
|
||||
onChange,
|
||||
onDropdownVisibleChange,
|
||||
handleClear,
|
||||
getDefaultValue,
|
||||
applyDefaultIfNeeded,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
@@ -68,93 +68,81 @@ function QueryVariableInput({
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
|
||||
// This is just a check given the previously undefined typed name prop. Not significant
|
||||
// This will be changed when we change the schema
|
||||
// TODO: @AshwinBhatkal Perses
|
||||
if (!variableData.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the response is not an array, premature return
|
||||
if (
|
||||
!variablesRes?.variableValues ||
|
||||
!Array.isArray(variablesRes?.variableValues)
|
||||
variablesRes?.variableValues &&
|
||||
Array.isArray(variablesRes?.variableValues)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedNewOptions = sortValues(
|
||||
variablesRes.variableValues,
|
||||
variableData.sort,
|
||||
);
|
||||
const sortedOldOptions = sortValues(optionsData, variableData.sort);
|
||||
|
||||
// if options are the same as before, no need to update state or check for selected value validity
|
||||
// ! selectedValue needs to be set in the first pass though, as options are initially empty array and we need to apply default if needed
|
||||
// Expecatation is that when oldOptions are not empty, then there is always some selectedValue
|
||||
if (areArraysEqual(sortedNewOptions, sortedOldOptions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionsData(sortedNewOptions);
|
||||
|
||||
let isSelectedValueMissingInNewOptions = false;
|
||||
|
||||
// Check if currently selected value(s) are present in the new options list
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
isSelectedValueMissingInNewOptions = variableData.selectedValue.some(
|
||||
(val) => !sortedNewOptions.includes(val),
|
||||
const newOptionsData = sortValues(
|
||||
variablesRes?.variableValues,
|
||||
variableData.sort,
|
||||
);
|
||||
} else if (
|
||||
variableData.selectedValue &&
|
||||
!sortedNewOptions.includes(variableData.selectedValue)
|
||||
) {
|
||||
isSelectedValueMissingInNewOptions = true;
|
||||
}
|
||||
|
||||
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
|
||||
// This block does not depend on selected value because of ALL and also because we would only come here if options are different from the previous
|
||||
if (
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption &&
|
||||
variableData.allSelected &&
|
||||
isSelectedValueMissingInNewOptions
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, sortedNewOptions, true);
|
||||
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
|
||||
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(sortedNewOptions.map((option) => option.toString()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
|
||||
let valueNotInList = false;
|
||||
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
variableData.selectedValue.forEach((val) => {
|
||||
if (!newOptionsData.includes(val)) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
isString(variableData.selectedValue) &&
|
||||
!newOptionsData.includes(variableData.selectedValue)
|
||||
) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
sortedNewOptions.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
sortedNewOptions.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
if (variableData.name && (valueNotInList || variableData.allSelected)) {
|
||||
if (
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
newOptionsData,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
} else {
|
||||
const defaultValue = getDefaultValue(sortedNewOptions);
|
||||
if (defaultValue !== undefined) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
defaultValue,
|
||||
allSelected,
|
||||
);
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(newOptionsData.map((option) => option.toString()));
|
||||
}
|
||||
} else {
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOptionsData(newOptionsData);
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
applyDefaultIfNeeded(newOptionsData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -167,7 +155,7 @@ function QueryVariableInput({
|
||||
onValueUpdate,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
getDefaultValue,
|
||||
applyDefaultIfNeeded,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { act, render } from '@testing-library/react';
|
||||
import * as dashboardVariablesStoreModule from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
dashboardVariablesStore,
|
||||
setDashboardVariablesStore,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
IDashboardVariablesStoreState,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
initializeVariableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
@@ -19,17 +17,6 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DashboardVariableSelection from '../DashboardVariableSelection';
|
||||
|
||||
// Mutable container to capture the onValueUpdate callback from VariableItem
|
||||
const mockVariableItemCallbacks: {
|
||||
onValueUpdate?: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
} = {};
|
||||
|
||||
// Mock providers/Dashboard/Dashboard
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
@@ -69,14 +56,10 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
|
||||
// Mock VariableItem to avoid rendering complexity
|
||||
jest.mock('../VariableItem', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any): JSX.Element => {
|
||||
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
|
||||
return <div data-testid="variable-item" />;
|
||||
},
|
||||
default: (): JSX.Element => <div data-testid="variable-item" />,
|
||||
}));
|
||||
|
||||
function createVariable(
|
||||
@@ -217,162 +200,4 @@ describe('DashboardVariableSelection', () => {
|
||||
expect(initializeVariableFetchStore).not.toHaveBeenCalled();
|
||||
expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('onValueUpdate', () => {
|
||||
let updateStoreSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
jest.clearAllMocks();
|
||||
// Real implementation pass-through — we just want to observe calls
|
||||
updateStoreSpy = jest.spyOn(
|
||||
dashboardVariablesStoreModule,
|
||||
'updateDashboardVariablesStore',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
updateStoreSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('updates dashboardVariablesStore synchronously before enqueueDescendantsOfVariable', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
|
||||
const callOrder: string[] = [];
|
||||
updateStoreSpy.mockImplementation(() => {
|
||||
callOrder.push('updateDashboardVariablesStore');
|
||||
});
|
||||
(enqueueDescendantsOfVariable as jest.Mock).mockImplementation(() => {
|
||||
callOrder.push('enqueueDescendantsOfVariable');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
'production',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'updateDashboardVariablesStore',
|
||||
'enqueueDescendantsOfVariable',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes updated variable value to dashboardVariablesStore', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
id: 'env-id',
|
||||
order: 0,
|
||||
selectedValue: 'staging',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
|
||||
// Clear spy calls that happened during setup/render
|
||||
updateStoreSpy.mockClear();
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
'production',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(updateStoreSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardId: 'dash-1',
|
||||
variables: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
selectedValue: 'production',
|
||||
allSelected: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls enqueueDescendantsOfVariable synchronously without a timer', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
'production',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Must be called immediately — no timer advancement needed
|
||||
expect(enqueueDescendantsOfVariable).toHaveBeenCalledWith('env');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('propagates allSelected and haveCustomValuesSelected to the store', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
id: 'env-id',
|
||||
order: 0,
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
updateStoreSpy.mockClear();
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
['production', 'staging'],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(updateStoreSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
selectedValue: ['production', 'staging'],
|
||||
allSelected: true,
|
||||
haveCustomValuesSelected: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { variableFetchStore } from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import QueryVariableInput from '../QueryVariableInput';
|
||||
|
||||
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
children,
|
||||
queryClient,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
queryClient: QueryClient;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function createVariable(
|
||||
overrides: Partial<IDashboardVariable> = {},
|
||||
): IDashboardVariable {
|
||||
return {
|
||||
id: 'env-id',
|
||||
name: 'env',
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
showALLOption: false,
|
||||
multiSelect: false,
|
||||
order: 0,
|
||||
queryValue: 'SELECT env FROM table',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Put the named variable into 'loading' state so useQuery fires on mount */
|
||||
function setVariableLoading(name: string): void {
|
||||
variableFetchStore.update((draft) => {
|
||||
draft.states[name] = 'loading';
|
||||
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function resetFetchStore(): void {
|
||||
variableFetchStore.set(() => ({
|
||||
states: {},
|
||||
lastUpdated: {},
|
||||
cycleIds: {},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('QueryVariableInput - getOptions logic', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetFetchStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetFetchStore();
|
||||
});
|
||||
|
||||
it('applies default value (first option) when selectedValue is empty on first load', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging', 'dev'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: undefined });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
'production', // first option by default
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps existing selectedValue when it is present in new options', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: 'staging' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
'staging',
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('selects all new options when allSelected=true and value is missing from new options', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({
|
||||
selectedValue: ['old-env'],
|
||||
allSelected: true,
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
});
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
['production', 'staging'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onValueUpdate a second time when options have not changed', async () => {
|
||||
const mockQueryFn = jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
(dashboardVariablesQuery as jest.Mock).mockImplementation(mockQueryFn);
|
||||
|
||||
const variable = createVariable({ selectedValue: 'production' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for first fetch and onValueUpdate call
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockOnValueUpdate.mockClear();
|
||||
|
||||
// Trigger a second fetch cycle with the same API response
|
||||
act(() => {
|
||||
variableFetchStore.update((draft) => {
|
||||
draft.states['env'] = 'revalidating';
|
||||
draft.cycleIds['env'] = (draft.cycleIds['env'] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for second query to fire
|
||||
await waitFor(() => {
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Options are unchanged, so onValueUpdate must not fire again
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onValueUpdate when API returns a non-array response', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: null },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: 'production' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dashboardVariablesQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire the query when variableData.name is empty', () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production'] },
|
||||
});
|
||||
|
||||
// Variable with no name — useVariableFetchState will be called with ''
|
||||
// and the query key will have an empty name, leaving it disabled
|
||||
const variable = createVariable({ name: '' });
|
||||
// Note: we do NOT put it in 'loading' state since name is empty
|
||||
// (no variableFetchStore entry for '' means isVariableFetching=false)
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(dashboardVariablesQuery).not.toHaveBeenCalled();
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,9 +46,6 @@ interface UseDashboardVariableSelectHelperReturn {
|
||||
applyDefaultIfNeeded: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => void;
|
||||
getDefaultValue: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => string | string[] | undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -251,6 +248,5 @@ export function useDashboardVariableSelectHelper({
|
||||
defaultValue,
|
||||
onChange,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,23 +121,9 @@ function BodyTitleRenderer({
|
||||
return (
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<span
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
>
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['click']}
|
||||
dropdownRender={(originNode): React.ReactNode => (
|
||||
<div data-log-detail-ignore="true">{originNode}</div>
|
||||
)}
|
||||
>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
</span>
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
|
||||
@@ -13,7 +13,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
|
||||
<span className="field-renderer-container">
|
||||
{dataType && newField && logType ? (
|
||||
<>
|
||||
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
|
||||
<Tooltip placement="left" title={newField}>
|
||||
<Typography.Text ellipsis className="label">
|
||||
{newField}{' '}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { InputRef } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
@@ -47,23 +46,12 @@ function Overview({
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isAttributesExpanded, setIsAttributesExpanded] = useState<boolean>(
|
||||
true,
|
||||
);
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
|
||||
const focusTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const searchInputRef = useCallback((node: InputRef | null) => {
|
||||
clearTimeout(focusTimerRef.current);
|
||||
if (node) {
|
||||
focusTimerRef.current = setTimeout(() => node.focus(), 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => (): void => clearTimeout(focusTimerRef.current), []);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const options: EditorProps['options'] = {
|
||||
@@ -208,7 +196,7 @@ function Overview({
|
||||
<>
|
||||
{isSearchVisible && (
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder="Search for a field..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
|
||||
@@ -245,7 +245,7 @@ function TableView({
|
||||
<Typography.Text>{renderedField}</Typography.Text>
|
||||
|
||||
{traceId && (
|
||||
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
|
||||
<Tooltip title="Inspect in Trace">
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
onClick={(
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import { getColorsForSeverityLabels, isRedLike } from '../utils';
|
||||
|
||||
describe('getColorsForSeverityLabels', () => {
|
||||
it('should return slate for blank labels', () => {
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
|
||||
});
|
||||
|
||||
it('should return correct colors for known severity variants', () => {
|
||||
expect(getColorsForSeverityLabels('INFO', 0)).toBe(Color.BG_ROBIN_600);
|
||||
expect(getColorsForSeverityLabels('ERROR', 0)).toBe(Color.BG_CHERRY_600);
|
||||
expect(getColorsForSeverityLabels('WARN', 0)).toBe(Color.BG_AMBER_600);
|
||||
expect(getColorsForSeverityLabels('DEBUG', 0)).toBe(Color.BG_AQUA_600);
|
||||
expect(getColorsForSeverityLabels('TRACE', 0)).toBe(Color.BG_FOREST_600);
|
||||
expect(getColorsForSeverityLabels('FATAL', 0)).toBe(Color.BG_SAKURA_600);
|
||||
});
|
||||
|
||||
it('should return non-red colors for unrecognized labels at any index', () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const color = getColorsForSeverityLabels('4', i);
|
||||
expect(isRedLike(color)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return non-red colors for numeric severity text', () => {
|
||||
const numericLabels = ['1', '2', '4', '9', '13', '17', '21'];
|
||||
numericLabels.forEach((label) => {
|
||||
const color = getColorsForSeverityLabels(label, 0);
|
||||
expect(isRedLike(color)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
|
||||
// Function to determine if a color is "red-like" based on its RGB values
|
||||
export function isRedLike(hex: string): boolean {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return r > 180 && r > g * 1.4 && r > b * 1.4;
|
||||
}
|
||||
|
||||
const SAFE_FALLBACK_COLORS = colors.filter((c) => !isRedLike(c));
|
||||
|
||||
const SEVERITY_VARIANT_COLORS: Record<string, string> = {
|
||||
TRACE: Color.BG_FOREST_600,
|
||||
Trace: Color.BG_FOREST_500,
|
||||
@@ -76,13 +67,8 @@ export function getColorsForSeverityLabels(
|
||||
label: string,
|
||||
index: number,
|
||||
): string {
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
// Check if we have a direct mapping for this severity variant
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[label.trim()];
|
||||
if (variantColor) {
|
||||
return variantColor;
|
||||
}
|
||||
@@ -117,8 +103,5 @@ export function getColorsForSeverityLabels(
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
);
|
||||
return colors[index % colors.length] || themeColors.red;
|
||||
}
|
||||
|
||||
@@ -111,19 +111,23 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => (
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
),
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => {
|
||||
return (
|
||||
<div key={log.id as string}>
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
tableColumns,
|
||||
onSetActiveLog,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useIsTextSelected } from 'hooks/useIsTextSelected';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import useLogDetailHandlers from '../useLogDetailHandlers';
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog');
|
||||
jest.mock('hooks/useIsTextSelected');
|
||||
|
||||
const mockOnSetActiveLog = jest.fn();
|
||||
const mockOnClearActiveLog = jest.fn();
|
||||
const mockOnAddToQuery = jest.fn();
|
||||
const mockOnGroupByAttribute = jest.fn();
|
||||
const mockIsTextSelected = jest.fn();
|
||||
|
||||
const mockLog: ILog = {
|
||||
id: 'log-1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
date: '2024-01-01',
|
||||
body: 'test log body',
|
||||
severityText: 'INFO',
|
||||
severityNumber: 9,
|
||||
traceFlags: 0,
|
||||
traceId: '',
|
||||
spanID: '',
|
||||
attributesString: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributes_string: {},
|
||||
severity_text: '',
|
||||
severity_number: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(useIsTextSelected).mockReturnValue(mockIsTextSelected);
|
||||
|
||||
jest.mocked(useActiveLog).mockReturnValue({
|
||||
activeLog: null,
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not open log detail when text is selected', () => {
|
||||
mockIsTextSelected.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetActiveLog(mockLog);
|
||||
});
|
||||
|
||||
expect(mockOnSetActiveLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open log detail when no text is selected', () => {
|
||||
mockIsTextSelected.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetActiveLog(mockLog);
|
||||
});
|
||||
|
||||
expect(mockOnSetActiveLog).toHaveBeenCalledWith(mockLog);
|
||||
});
|
||||
|
||||
it('should toggle off when clicking the same active log', () => {
|
||||
mockIsTextSelected.mockReturnValue(false);
|
||||
|
||||
jest.mocked(useActiveLog).mockReturnValue({
|
||||
activeLog: mockLog,
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetActiveLog(mockLog);
|
||||
});
|
||||
|
||||
expect(mockOnClearActiveLog).toHaveBeenCalled();
|
||||
expect(mockOnSetActiveLog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -56,20 +54,6 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
// Close drawer/clear active log when query in URL changes
|
||||
const urlQuery = useUrlQuery();
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
|
||||
const prevQueryRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevQueryRef.current !== null &&
|
||||
prevQueryRef.current !== compositeQuery
|
||||
) {
|
||||
setActiveLog(null);
|
||||
}
|
||||
prevQueryRef.current = compositeQuery;
|
||||
}, [compositeQuery]);
|
||||
|
||||
const onSetDetailedLogData = useCallback(
|
||||
(logData: ILog) => {
|
||||
dispatch({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useState } from 'react';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import type { UseActiveLog } from 'hooks/logs/types';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useIsTextSelected } from 'hooks/useIsTextSelected';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
type SelectedTab = typeof VIEW_TYPES[keyof typeof VIEW_TYPES] | undefined;
|
||||
@@ -29,13 +28,9 @@ function useLogDetailHandlers({
|
||||
onAddToQuery,
|
||||
} = useActiveLog();
|
||||
const [selectedTab, setSelectedTab] = useState<SelectedTab>(defaultTab);
|
||||
const isTextSelected = useIsTextSelected();
|
||||
|
||||
const handleSetActiveLog = useCallback(
|
||||
(log: ILog, nextTab: SelectedTab = defaultTab): void => {
|
||||
if (isTextSelected()) {
|
||||
return;
|
||||
}
|
||||
if (activeLog?.id === log.id) {
|
||||
onClearActiveLog();
|
||||
setSelectedTab(undefined);
|
||||
@@ -44,7 +39,7 @@ function useLogDetailHandlers({
|
||||
onSetActiveLog(log);
|
||||
setSelectedTab(nextTab ?? defaultTab);
|
||||
},
|
||||
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog, isTextSelected],
|
||||
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog],
|
||||
);
|
||||
|
||||
const handleCloseLogDetail = useCallback((): void => {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useIsTextSelected(): () => boolean {
|
||||
return useCallback((): boolean => {
|
||||
const selection = window.getSelection();
|
||||
return (
|
||||
!!selection && !selection.isCollapsed && selection.toString().length > 0
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
12
pkg/apiserver/signozapiserver/logspipeline.go
Normal file
12
pkg/apiserver/signozapiserver/logspipeline.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addLogspipelineRoutes(router *mux.Router) error {
|
||||
router.HandleFunc("/api/v2/pipelines", provider.logspipelineHandler.ListPipelines).Methods(http.MethodGet)
|
||||
return nil
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -48,6 +49,7 @@ type provider struct {
|
||||
authzHandler authz.Handler
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
logspipelineHandler logspipeline.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -69,6 +71,7 @@ func NewFactory(
|
||||
authzHandler authz.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
logspipelineHandler logspipeline.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -93,6 +96,7 @@ func NewFactory(
|
||||
authzHandler,
|
||||
zeusHandler,
|
||||
querierHandler,
|
||||
logspipelineHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -119,6 +123,7 @@ func newProvider(
|
||||
authzHandler authz.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
logspipelineHandler logspipeline.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -143,6 +148,7 @@ func newProvider(
|
||||
authzHandler: authzHandler,
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
logspipelineHandler: logspipelineHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -223,9 +229,14 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addLogspipelineRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
|
||||
return []handler.OpenAPISecurityScheme{
|
||||
{Name: ctxtypes.AuthTypeAPIKey.StringValue(), Scopes: []string{role.String()}},
|
||||
|
||||
91
pkg/modules/logspipeline/impllogspipeline/handler.go
Normal file
91
pkg/modules/logspipeline/impllogspipeline/handler.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package impllogspipeline
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module logspipeline.Module
|
||||
}
|
||||
|
||||
func NewHandler(module logspipeline.Module) logspipeline.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) ListPipelines(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, errv2 := valuer.NewUUID(claims.OrgID)
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
return
|
||||
}
|
||||
|
||||
version, err := ParseAgentConfigVersion(r)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if version != -1 {
|
||||
pipelines, err := h.module.ListPipelinesByVersion(r.Context(), orgID, version)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
render.Success(w, http.StatusOK, pipelines)
|
||||
return
|
||||
}
|
||||
|
||||
pipelines, err := h.module.ListPipelines(r.Context(), orgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, pipelines)
|
||||
}
|
||||
|
||||
func (h *handler) GetPipeline(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) CreatePipeline(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) UpdatePipeline(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) DeletePipeline(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func ParseAgentConfigVersion(r *http.Request) (int, error) {
|
||||
versionString := mux.Vars(r)["version"]
|
||||
|
||||
if versionString == "latest" {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
version64, err := strconv.ParseInt(versionString, 0, 8)
|
||||
|
||||
if err != nil {
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid version number")
|
||||
}
|
||||
|
||||
if version64 <= 0 {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid version number")
|
||||
}
|
||||
|
||||
return int(version64), nil
|
||||
}
|
||||
322
pkg/modules/logspipeline/impllogspipeline/module.go
Normal file
322
pkg/modules/logspipeline/impllogspipeline/module.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package impllogspipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewModule(sqlstore sqlstore.SQLStore) logspipeline.Module {
|
||||
return &module{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (m *module) ListPipelines(ctx context.Context, orgID valuer.UUID) ([]pipelinetypes.GettablePipeline, error) {
|
||||
latestVersion := -1
|
||||
// get latest agent config
|
||||
lastestConfig, err := agentConf.GetLatestVersion(ctx, orgID, opamptypes.ElementTypeLogPipelines)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if lastestConfig != nil {
|
||||
latestVersion = lastestConfig.Version
|
||||
}
|
||||
return m.ListPipelinesByVersion(ctx, orgID, latestVersion)
|
||||
}
|
||||
|
||||
func (m *module) ListPipelinesByVersion(ctx context.Context, orgID valuer.UUID, version int) ([]pipelinetypes.GettablePipeline, error) {
|
||||
var stored []pipelinetypes.StoreablePipeline
|
||||
err := m.sqlstore.BunDB().NewSelect().
|
||||
Model(&stored).
|
||||
Join("JOIN agent_config_element e ON p.id = e.element_id").
|
||||
Join("JOIN agent_config_version v ON v.id = e.version_id").
|
||||
Where("e.element_type = ?", opamptypes.ElementTypeLogPipelines.StringValue()).
|
||||
Where("v.version = ?", version).
|
||||
Where("v.org_id = ?", orgID.StringValue()).
|
||||
Order("p.order_id ASC").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pipelines := make([]pipelinetypes.GettablePipeline, len(stored))
|
||||
if len(stored) == 0 {
|
||||
return pipelines, nil
|
||||
}
|
||||
|
||||
for i := range stored {
|
||||
pipelines[i].StoreablePipeline = stored[i]
|
||||
if err := pipelines[i].ParseRawConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pipelines[i].ParseFilter(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return pipelines, nil
|
||||
}
|
||||
|
||||
func (m *module) GetPipeline(ctx context.Context, orgID valuer.UUID, id string) (*pipelinetypes.GettablePipeline, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *module) CreatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error) {
|
||||
storeable, err := pipeline.ToStoreablePipeline()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// regenerate the id and set other fields
|
||||
storeable.Identifiable.ID = valuer.GenerateUUID()
|
||||
storeable.OrgID = orgID.String()
|
||||
storeable.TimeAuditable = types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
storeable.UserAuditable = types.UserAuditable{
|
||||
CreatedBy: claims.Email,
|
||||
}
|
||||
|
||||
_, err = m.sqlstore.BunDB().NewInsert().
|
||||
Model(&storeable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
zap.L().Error("error in inserting pipeline data", zap.Error(err))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to insert pipeline")
|
||||
}
|
||||
|
||||
return &pipelinetypes.GettablePipeline{
|
||||
StoreablePipeline: *storeable,
|
||||
Filter: pipeline.Filter,
|
||||
Config: pipeline.Config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) UpdatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error) {
|
||||
if err := pipeline.IsValid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storeable, err := pipeline.ToStoreablePipeline()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storeable.OrgID = orgID.String()
|
||||
storeable.TimeAuditable = types.TimeAuditable{
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
storeable.UserAuditable = types.UserAuditable{
|
||||
UpdatedBy: claims.Email,
|
||||
}
|
||||
|
||||
// get id from storeable pipeline
|
||||
id := storeable.ID.StringValue()
|
||||
|
||||
// depending on the order_id update the rest of the table
|
||||
// example 1: total available pipelines are 6, and order_id 5 is moved to 2, then we need to update the rest of the table
|
||||
// old: 1, 2, 3, 4, 5, 6
|
||||
// ^ |
|
||||
// |_________|
|
||||
// So pipelines starting from 2nd position till 4th position shift to right (or increase their order_id) by 1 position
|
||||
// example 2: total available pipelines are 6, and order_id 2 is moved to 4, then we need to update the rest of the table
|
||||
// old: 1, 2, 3, 4, 5, 6
|
||||
// | ^
|
||||
// |_____|
|
||||
// So pipelines starting from 3rd position till 4th position shift to left (or decrease their order_id) by 1 position
|
||||
if err := m.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
db := m.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
var existing pipelinetypes.StoreablePipeline
|
||||
if err := db.NewSelect().
|
||||
Column("order_id", "enabled").
|
||||
Model(&existing).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Scan(ctx); err != nil {
|
||||
return m.sqlstore.WrapNotFoundErrf(
|
||||
err,
|
||||
errors.CodeNotFound,
|
||||
"pipeline with id %s does not exist in org %s",
|
||||
id,
|
||||
orgID.StringValue(),
|
||||
)
|
||||
}
|
||||
|
||||
oldOrderID := existing.OrderID
|
||||
newOrderID := storeable.OrderID
|
||||
|
||||
// Reorder other pipelines if the order has changed.
|
||||
if newOrderID != oldOrderID {
|
||||
if err := reorderPipelinesInTx(ctx, db, orgID.StringValue(), oldOrderID, newOrderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve primary key and immutable fields.
|
||||
storeable.ID = existing.ID
|
||||
|
||||
// Persist the updated pipeline (including its new order).
|
||||
if _, err := db.NewUpdate().
|
||||
Model(storeable).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply pipelines if the enabled state has changed
|
||||
if existing.Enabled != storeable.Enabled {
|
||||
if err := m.applyPipelinesInTx(ctx, orgID, claims, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pipelinetypes.GettablePipeline{
|
||||
StoreablePipeline: *storeable,
|
||||
Filter: pipeline.Filter,
|
||||
Config: pipeline.Config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) applyPipelinesInTx(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, tx bun.IDB) error {
|
||||
// Get ids pipelines for the given org
|
||||
var pipelines []pipelinetypes.StoreablePipeline
|
||||
if err := tx.NewSelect().
|
||||
Column("id").
|
||||
Model(&pipelines).
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Scan(ctx); err != nil {
|
||||
return m.sqlstore.WrapNotFoundErrf(
|
||||
err,
|
||||
errors.CodeNotFound,
|
||||
"no pipelines found for org %s",
|
||||
orgID.StringValue(),
|
||||
)
|
||||
}
|
||||
|
||||
// prepare config elements
|
||||
elements := make([]string, len(pipelines))
|
||||
for i, p := range pipelines {
|
||||
elements[i] = p.ID.StringValue()
|
||||
}
|
||||
|
||||
cfg, err := agentConf.StartNewVersion(ctx, orgID, valuer.MustNewUUID(claims.UserID), opamptypes.ElementTypeLogPipelines, elements)
|
||||
if err != nil || cfg == nil {
|
||||
return errors.WithAdditionalf(err, "failed to start new version for org %s", orgID.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reorderPipelinesInTx updates order_id of other pipelines in a transaction-aware way.
|
||||
// It assumes that all pipelines for a given org have consecutive order_id values starting from 1.
|
||||
// The logic is:
|
||||
// - When moving a pipeline from a higher position to a lower position (e.g., 5 -> 2),
|
||||
// all pipelines in [newOrderID, oldOrderID) are shifted right by +1.
|
||||
// - When moving from a lower position to a higher position (e.g., 2 -> 4),
|
||||
// all pipelines in (oldOrderID, newOrderID] are shifted left by -1.
|
||||
func reorderPipelinesInTx(ctx context.Context, tx bun.IDB, orgID string, oldOrderID, newOrderID int) error {
|
||||
switch {
|
||||
case newOrderID < oldOrderID:
|
||||
// Move up: shift affected pipelines down (order_id + 1).
|
||||
_, err := tx.NewUpdate().
|
||||
Model((*pipelinetypes.StoreablePipeline)(nil)).
|
||||
Set("order_id = order_id + 1").
|
||||
Where("org_id = ?", orgID).
|
||||
Where("order_id >= ?", newOrderID).
|
||||
Where("order_id < ?", oldOrderID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
case newOrderID > oldOrderID:
|
||||
// Move down: shift affected pipelines up (order_id - 1).
|
||||
_, err := tx.NewUpdate().
|
||||
Model((*pipelinetypes.StoreablePipeline)(nil)).
|
||||
Set("order_id = order_id - 1").
|
||||
Where("org_id = ?", orgID).
|
||||
Where("order_id > ?", oldOrderID).
|
||||
Where("order_id <= ?", newOrderID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) DeletePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) error {
|
||||
if err := pipeline.IsValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
db := m.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
// Fetch existing pipeline to determine its current order_id.
|
||||
var existing pipelinetypes.StoreablePipeline
|
||||
if err := db.NewSelect().
|
||||
Model(&existing).
|
||||
Column("order_id", "enabled").
|
||||
Where("id = ?", pipeline.ID).
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Scan(ctx); err != nil {
|
||||
return m.sqlstore.WrapNotFoundErrf(
|
||||
err,
|
||||
errors.CodeNotFound,
|
||||
"pipeline with id %s does not exist in org %s",
|
||||
pipeline.ID,
|
||||
orgID.StringValue(),
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := db.NewDelete().
|
||||
Model((*pipelinetypes.StoreablePipeline)(nil)).
|
||||
Where("id = ?", pipeline.ID).
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set order_ids of other pipelines by collapsing the gap left by the deleted pipeline.
|
||||
if _, err := db.NewUpdate().
|
||||
Model((*pipelinetypes.StoreablePipeline)(nil)).
|
||||
Set("order_id = order_id - 1").
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Where("order_id > ?", existing.OrderID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply pipelines if the deleted pipeline was enabled
|
||||
if existing.Enabled {
|
||||
if err := m.applyPipelinesInTx(ctx, orgID, claims, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
27
pkg/modules/logspipeline/logspipeline.go
Normal file
27
pkg/modules/logspipeline/logspipeline.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package logspipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
ListPipelines(ctx context.Context, orgID valuer.UUID) ([]pipelinetypes.GettablePipeline, error)
|
||||
ListPipelinesByVersion(ctx context.Context, orgID valuer.UUID, version int) ([]pipelinetypes.GettablePipeline, error)
|
||||
GetPipeline(ctx context.Context, orgID valuer.UUID, id string) (*pipelinetypes.GettablePipeline, error)
|
||||
CreatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error)
|
||||
UpdatePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) (*pipelinetypes.GettablePipeline, error)
|
||||
DeletePipeline(ctx context.Context, orgID valuer.UUID, claims *authtypes.Claims, pipeline *pipelinetypes.PostablePipeline) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
ListPipelines(w http.ResponseWriter, r *http.Request)
|
||||
GetPipeline(w http.ResponseWriter, r *http.Request)
|
||||
CreatePipeline(w http.ResponseWriter, r *http.Request)
|
||||
UpdatePipeline(w http.ResponseWriter, r *http.Request)
|
||||
DeletePipeline(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
@@ -120,8 +120,6 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
|
||||
}
|
||||
}
|
||||
resultData.Rows = filteredRows
|
||||
case *qbtypes.ScalarData:
|
||||
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
|
||||
}
|
||||
|
||||
filteredData = append(filteredData, result)
|
||||
@@ -147,39 +145,6 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func filterScalarDataIPs(columns []*qbtypes.ColumnDescriptor, data [][]any) [][]any {
|
||||
// Find column indices for server address fields
|
||||
serverColIndices := make([]int, 0)
|
||||
for i, col := range columns {
|
||||
if col.Name == derivedKeyHTTPHost {
|
||||
serverColIndices = append(serverColIndices, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(serverColIndices) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
filtered := make([][]any, 0, len(data))
|
||||
for _, row := range data {
|
||||
includeRow := true
|
||||
for _, colIdx := range serverColIndices {
|
||||
if colIdx < len(row) {
|
||||
if strVal, ok := row[colIdx].(string); ok {
|
||||
if net.ParseIP(strVal) != nil {
|
||||
includeRow = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if includeRow {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func shouldIncludeRow(row *qbtypes.RawRow) bool {
|
||||
if row.Data != nil {
|
||||
if domainVal, ok := row.Data[derivedKeyHTTPHost]; ok {
|
||||
|
||||
@@ -117,59 +117,6 @@ func TestFilterResponse(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should filter out IP addresses from scalar data",
|
||||
input: []*qbtypes.QueryRangeResponse{
|
||||
{
|
||||
Data: qbtypes.QueryData{
|
||||
Results: []any{
|
||||
&qbtypes.ScalarData{
|
||||
QueryName: "endpoints",
|
||||
Columns: []*qbtypes.ColumnDescriptor{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
|
||||
Type: qbtypes.ColumnTypeGroup,
|
||||
},
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
},
|
||||
},
|
||||
Data: [][]any{
|
||||
{"192.168.1.1", 10},
|
||||
{"example.com", 20},
|
||||
{"10.0.0.1", 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*qbtypes.QueryRangeResponse{
|
||||
{
|
||||
Data: qbtypes.QueryData{
|
||||
Results: []any{
|
||||
&qbtypes.ScalarData{
|
||||
QueryName: "endpoints",
|
||||
Columns: []*qbtypes.ColumnDescriptor{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
|
||||
Type: qbtypes.ColumnTypeGroup,
|
||||
},
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
},
|
||||
},
|
||||
Data: [][]any{
|
||||
{"example.com", 20},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
@@ -4048,26 +4049,6 @@ func (aH *APIHandler) logAggregate(w http.ResponseWriter, r *http.Request) {
|
||||
aH.WriteJSON(w, r, model.GetLogsAggregatesResponse{})
|
||||
}
|
||||
|
||||
func parseAgentConfigVersion(r *http.Request) (int, error) {
|
||||
versionString := mux.Vars(r)["version"]
|
||||
|
||||
if versionString == "latest" {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
version64, err := strconv.ParseInt(versionString, 0, 8)
|
||||
|
||||
if err != nil {
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid version number")
|
||||
}
|
||||
|
||||
if version64 <= 0 {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid version number")
|
||||
}
|
||||
|
||||
return int(version64), nil
|
||||
}
|
||||
|
||||
func (aH *APIHandler) PreviewLogsPipelinesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
req := logparsingpipeline.PipelinesPreviewRequest{}
|
||||
|
||||
@@ -4098,7 +4079,7 @@ func (aH *APIHandler) ListLogsPipelinesHandler(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
version, err := parseAgentConfigVersion(r)
|
||||
version, err := impllogspipeline.ParseAgentConfigVersion(r)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -66,6 +68,7 @@ type Modules struct {
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
Promote promote.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -110,5 +113,6 @@ func NewModules(
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -59,6 +60,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ authz.Handler }{},
|
||||
struct{ zeus.Handler }{},
|
||||
struct{ querier.Handler }{},
|
||||
struct{ logspipeline.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/global/signozglobal"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
@@ -169,7 +170,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddAnonymousPublicDashboardTransactionFactory(sqlstore),
|
||||
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.AuthzHandler,
|
||||
handlers.ZeusHandler,
|
||||
handlers.QuerierHandler,
|
||||
impllogspipeline.NewHandler(modules.LogsPipeline),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type migrateRulesV4ToV5 struct {
|
||||
store sqlstore.SQLStore
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMigrateRulesV4ToV5Factory(
|
||||
store sqlstore.SQLStore,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("migrate_rules_post_deprecation"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateRulesV4ToV5{
|
||||
store: store,
|
||||
telemetryStore: telemetryStore,
|
||||
logger: ps.Logger,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV4ToV5) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV4ToV5) getLogDuplicateKeys(ctx context.Context) ([]string, error) {
|
||||
query := `
|
||||
SELECT name
|
||||
FROM (
|
||||
SELECT DISTINCT name FROM signoz_logs.distributed_logs_attribute_keys
|
||||
INTERSECT
|
||||
SELECT DISTINCT name FROM signoz_logs.distributed_logs_resource_keys
|
||||
)
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
|
||||
if err != nil {
|
||||
migration.logger.WarnContext(ctx, "failed to query log duplicate keys", "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
migration.logger.WarnContext(ctx, "failed to scan log duplicate key", "error", err)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV4ToV5) getTraceDuplicateKeys(ctx context.Context) ([]string, error) {
|
||||
query := `
|
||||
SELECT tagKey
|
||||
FROM signoz_traces.distributed_span_attributes_keys
|
||||
WHERE tagType IN ('tag', 'resource')
|
||||
GROUP BY tagKey
|
||||
HAVING COUNT(DISTINCT tagType) > 1
|
||||
ORDER BY tagKey
|
||||
`
|
||||
|
||||
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
|
||||
if err != nil {
|
||||
migration.logger.WarnContext(ctx, "failed to query trace duplicate keys", "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
migration.logger.WarnContext(ctx, "failed to scan trace duplicate key", "error", err)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV4ToV5) Up(ctx context.Context, db *bun.DB) error {
|
||||
logsKeys, err := migration.getLogDuplicateKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracesKeys, err := migration.getTraceDuplicateKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
var rules []struct {
|
||||
ID string `bun:"id"`
|
||||
Data map[string]any `bun:"data"`
|
||||
}
|
||||
|
||||
err = tx.NewSelect().
|
||||
Table("rule").
|
||||
Column("id", "data").
|
||||
Scan(ctx, &rules)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
alertsMigrator := transition.NewAlertMigrateV5(migration.logger, logsKeys, tracesKeys)
|
||||
|
||||
count := 0
|
||||
|
||||
for _, rule := range rules {
|
||||
version, _ := rule.Data["version"].(string)
|
||||
|
||||
if version == "v5" {
|
||||
continue
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
migration.logger.WarnContext(ctx, "unexpected empty version for rule", "rule_id", rule.ID)
|
||||
}
|
||||
|
||||
migration.logger.InfoContext(ctx, "migrating rule v4 to v5", "rule_id", rule.ID, "current_version", version)
|
||||
|
||||
// Check if the queries envelope already exists and is non-empty
|
||||
hasQueriesEnvelope := false
|
||||
if condition, ok := rule.Data["condition"].(map[string]any); ok {
|
||||
if compositeQuery, ok := condition["compositeQuery"].(map[string]any); ok {
|
||||
if queries, ok := compositeQuery["queries"].([]any); ok && len(queries) > 0 {
|
||||
hasQueriesEnvelope = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasQueriesEnvelope {
|
||||
// already has queries envelope, just bump version
|
||||
// this is because user made a mistake of choosing version
|
||||
migration.logger.InfoContext(ctx, "rule already has queries envelope, bumping version", "rule_id", rule.ID)
|
||||
rule.Data["version"] = "v5"
|
||||
} else {
|
||||
// old format, run full migration
|
||||
migration.logger.InfoContext(ctx, "rule has old format, running full migration", "rule_id", rule.ID)
|
||||
updated := alertsMigrator.Migrate(ctx, rule.Data)
|
||||
if !updated {
|
||||
migration.logger.WarnContext(ctx, "expected updated to be true but got false", "rule_id", rule.ID)
|
||||
continue
|
||||
}
|
||||
rule.Data["version"] = "v5"
|
||||
}
|
||||
|
||||
dataJSON, err := json.Marshal(rule.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.NewUpdate().
|
||||
Table("rule").
|
||||
Set("data = ?", string(dataJSON)).
|
||||
Where("id = ?", rule.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count != 0 {
|
||||
migration.logger.InfoContext(ctx, "migrate v4 alerts", "count", count)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV4ToV5) Down(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -266,6 +267,37 @@ func (p *PostablePipeline) IsValid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostablePipeline) ToStoreablePipeline() (*StoreablePipeline, error) {
|
||||
rawConfig, err := json.Marshal(p.Config)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to unmarshal postable pipeline config")
|
||||
}
|
||||
|
||||
filter, err := json.Marshal(p.Filter)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to marshal postable pipeline filter")
|
||||
}
|
||||
identifier := valuer.GenerateUUID()
|
||||
if p.ID != "" {
|
||||
identifier, err = valuer.NewUUID(p.ID)
|
||||
if err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "failed to parse postable pipeline id")
|
||||
}
|
||||
}
|
||||
return &StoreablePipeline{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: identifier,
|
||||
},
|
||||
OrderID: p.OrderID,
|
||||
Enabled: p.Enabled,
|
||||
Name: p.Name,
|
||||
Alias: p.Alias,
|
||||
Description: p.Description,
|
||||
FilterString: string(filter),
|
||||
ConfigJSON: string(rawConfig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isValidOperator(op PipelineOperator) error {
|
||||
if op.ID == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "PipelineOperator.ID is required")
|
||||
|
||||
@@ -355,10 +355,6 @@ func (r *PostableRule) validate() error {
|
||||
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "composite query is required"))
|
||||
}
|
||||
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "only version v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
if isAllQueriesDisabled(r.RuleCondition.CompositeQuery) {
|
||||
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "all queries are disabled in rule condition"))
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"ruleType": "threshold_rule",
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -151,7 +150,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "DefaultsRule",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -189,7 +187,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "PromQLRule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "promql",
|
||||
@@ -259,7 +256,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "SeverityLabelTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -348,7 +344,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "NoLabelsTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -389,7 +384,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "OverwriteTest",
|
||||
"schemaVersion": "v1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -480,7 +474,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "V2Test",
|
||||
"schemaVersion": "v2",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -524,7 +517,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "DefaultSchemaTest",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -577,7 +569,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"alert": "TestThresholds",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -648,7 +639,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
"schemaVersion": "v2",
|
||||
"alert": "MultiThresholdAlert",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -742,7 +732,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -777,7 +766,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -811,7 +799,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyAboveTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -846,7 +833,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyAboveTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -880,7 +866,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowAllTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -916,7 +901,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyBelowAllTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -951,7 +935,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "AnomalyOutOfBoundsTest",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -986,7 +969,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "ThresholdTest",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -1021,7 +1003,6 @@ func TestAnomalyNegationEval(t *testing.T) {
|
||||
ruleJSON: []byte(`{
|
||||
"alert": "ThresholdTest",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
|
||||
Reference in New Issue
Block a user