mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-20 10:20:48 +01:00
Compare commits
27 Commits
v0.102.0
...
tvats-impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02f127423b | ||
|
|
ab8a63bc51 | ||
|
|
12c9b921a7 | ||
|
|
52228bc6c4 | ||
|
|
79988b448f | ||
|
|
4bfd7ba3d7 | ||
|
|
3349158213 | ||
|
|
1c9f4efb9f | ||
|
|
fd839ff1db | ||
|
|
09cbe4aa0d | ||
|
|
096e38ee91 | ||
|
|
48590c03e2 | ||
|
|
38af897bcc | ||
|
|
2b79678e63 | ||
|
|
a4f54baf1f | ||
|
|
4e6c42dd17 | ||
|
|
c2393c74fd | ||
|
|
05f3b68bcf | ||
|
|
05d5746962 | ||
|
|
39bd169b89 | ||
|
|
8491604454 | ||
|
|
45cdbbe94a | ||
|
|
d85ad40a90 | ||
|
|
c7c2d2a7ef | ||
|
|
84a03438da | ||
|
|
b650d7d8db | ||
|
|
6f71238c0f |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -18,6 +18,7 @@ jobs:
|
||||
- passwordauthn
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- querier
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
|
||||
2
Makefile
2
Makefile
@@ -86,7 +86,7 @@ go-run-enterprise: ## Runs the enterprise go backend server
|
||||
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
|
||||
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
|
||||
go run -race \
|
||||
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go
|
||||
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go server
|
||||
|
||||
.PHONY: go-test
|
||||
go-test: ## Runs go unit tests
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.102.0
|
||||
image: signoz/signoz:v0.102.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.102.0
|
||||
image: signoz/signoz:v0.102.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.102.0}
|
||||
image: signoz/signoz:${VERSION:-v0.102.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.102.0}
|
||||
image: signoz/signoz:${VERSION:-v0.102.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -246,7 +246,9 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
continue
|
||||
}
|
||||
}
|
||||
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
||||
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
|
||||
ActiveAlerts: r.ActiveAlertsLabelFP(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -296,7 +298,9 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
|
||||
continue
|
||||
}
|
||||
}
|
||||
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
||||
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
|
||||
ActiveAlerts: r.ActiveAlertsLabelFP(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -410,6 +414,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
GeneratorURL: r.GeneratorURL(),
|
||||
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
|
||||
Missing: smpl.IsMissing,
|
||||
IsRecovering: smpl.IsRecovering,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +427,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
|
||||
alert.Value = a.Value
|
||||
alert.Annotations = a.Annotations
|
||||
// Update the recovering and missing state of existing alert
|
||||
alert.IsRecovering = a.IsRecovering
|
||||
alert.Missing = a.Missing
|
||||
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
|
||||
alert.Receivers = ruleReceiverMap[v]
|
||||
}
|
||||
@@ -480,6 +488,30 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
|
||||
// We need to change firing alert to recovering if the returned sample meets recovery threshold
|
||||
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
|
||||
// We need to change recovering alerts to firing if the returned sample meets target threshold
|
||||
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
|
||||
// in any of the above case we need to update the status of alert
|
||||
if changeFiringToRecovering || changeRecoveringToFiring {
|
||||
state := model.StateRecovering
|
||||
if changeRecoveringToFiring {
|
||||
state = model.StateFiring
|
||||
}
|
||||
a.State = state
|
||||
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
|
||||
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
|
||||
RuleID: r.ID(),
|
||||
RuleName: r.Name(),
|
||||
State: state,
|
||||
StateChanged: true,
|
||||
UnixMilli: ts.UnixMilli(),
|
||||
Labels: model.LabelsString(labelsJSON),
|
||||
Fingerprint: a.QueryResultLables.Hash(),
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
currentState := r.State()
|
||||
|
||||
@@ -30,6 +30,8 @@ func (formatter Formatter) DataTypeOf(dataType string) sqlschema.DataType {
|
||||
return sqlschema.DataTypeBoolean
|
||||
case "VARCHAR", "CHARACTER VARYING", "CHARACTER":
|
||||
return sqlschema.DataTypeText
|
||||
case "BYTEA":
|
||||
return sqlschema.DataTypeBytea
|
||||
}
|
||||
|
||||
return formatter.Formatter.DataTypeOf(dataType)
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
interface ConfigureIconProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ConfigureIcon({
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
color,
|
||||
}: ConfigureIconProps): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width}
|
||||
height={height}
|
||||
fill={fill}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
stroke="#C0C1C3"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.333"
|
||||
d="M9.71 4.745a.576.576 0 000 .806l.922.922a.576.576 0 00.806 0l2.171-2.171a3.455 3.455 0 01-4.572 4.572l-3.98 3.98a1.222 1.222 0 11-1.727-1.728l3.98-3.98a3.455 3.455 0 014.572-4.572L9.717 4.739l-.006.006z"
|
||||
/>
|
||||
<path
|
||||
stroke="#C0C1C3"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.333"
|
||||
d="M4 7L2.527 5.566a1.333 1.333 0 01-.013-1.898l.81-.81a1.333 1.333 0 011.991.119L5.333 3m5.417 7.988l1.179 1.178m0 0l-.138.138a.833.833 0 00.387 1.397v0a.833.833 0 00.792-.219l.446-.446a.833.833 0 00.176-.917v0a.833.833 0 00-1.355-.261l-.308.308z"
|
||||
@@ -36,6 +36,6 @@ function ConfigureIcon({
|
||||
ConfigureIcon.defaultProps = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
fill: 'none',
|
||||
color: 'currentColor',
|
||||
};
|
||||
export default ConfigureIcon;
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
@@ -45,6 +44,12 @@
|
||||
border-bottom-right-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCopyToClipboard } from 'react-use';
|
||||
function CopyClipboardHOC({
|
||||
entityKey,
|
||||
textToCopy,
|
||||
tooltipText = 'Copy to clipboard',
|
||||
children,
|
||||
}: CopyClipboardHOCProps): JSX.Element {
|
||||
const [value, setCopy] = useCopyToClipboard();
|
||||
@@ -31,7 +32,7 @@ function CopyClipboardHOC({
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>Copy to clipboard</span>}
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
@@ -42,7 +43,11 @@ function CopyClipboardHOC({
|
||||
interface CopyClipboardHOCProps {
|
||||
entityKey: string | undefined;
|
||||
textToCopy: string;
|
||||
tooltipText?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default CopyClipboardHOC;
|
||||
CopyClipboardHOC.defaultProps = {
|
||||
tooltipText: 'Copy to clipboard',
|
||||
};
|
||||
|
||||
@@ -251,6 +251,10 @@
|
||||
.ant-input-group-addon {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
|
||||
@@ -179,6 +179,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
isListViewPanel={isListViewPanel}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
queriesCount={1}
|
||||
/>
|
||||
) : (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
@@ -200,6 +201,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
signalSource={query.source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
queriesCount={currentQuery.builder.queryData.length}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -98,6 +98,13 @@
|
||||
border-radius: 2px;
|
||||
border: 1.005px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Ink-300, #16181d);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.input-with-label {
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.source-selector {
|
||||
width: 120px;
|
||||
}
|
||||
@@ -22,6 +31,11 @@
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
min-height: 36px;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
|
||||
@@ -236,6 +236,10 @@
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-activeLine > span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +275,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
.cm-placeholder {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
|
||||
&.error {
|
||||
.cm-editor {
|
||||
@@ -231,6 +233,9 @@
|
||||
.query-aggregation-interval-input {
|
||||
input {
|
||||
max-width: 120px;
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.add-trace-operator-button,
|
||||
.add-new-query-button,
|
||||
.add-formula-button {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './QueryFooter.styles.scss';
|
||||
|
||||
/* eslint-disable react/require-default-props */
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import { DraftingCompass, Plus, Sigma } from 'lucide-react';
|
||||
@@ -22,8 +24,7 @@ export default function QueryFooter({
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn secondary"
|
||||
type="text"
|
||||
className="add-new-query-button periscope-btn "
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
/>
|
||||
@@ -49,7 +50,7 @@ export default function QueryFooter({
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-formula-button periscope-btn secondary"
|
||||
className="add-formula-button periscope-btn "
|
||||
icon={<Sigma size={16} />}
|
||||
onClick={addNewFormula}
|
||||
>
|
||||
@@ -77,7 +78,7 @@ export default function QueryFooter({
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-trace-operator-button periscope-btn secondary"
|
||||
className="add-trace-operator-button periscope-btn "
|
||||
icon={<DraftingCompass size={16} />}
|
||||
onClick={(): void => addTraceOperator?.()}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
@@ -79,6 +80,16 @@ const stopEventsExtension = EditorView.domEventHandlers({
|
||||
},
|
||||
});
|
||||
|
||||
interface QuerySearchProps {
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
dataSource: DataSource;
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
placeholder,
|
||||
onChange,
|
||||
@@ -87,17 +98,8 @@ function QuerySearch({
|
||||
onRun,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
dataSource: DataSource;
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
}): JSX.Element {
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
const [activeKey, setActiveKey] = useState<string>('');
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
@@ -107,8 +109,12 @@ function QuerySearch({
|
||||
message: '',
|
||||
errors: [],
|
||||
});
|
||||
const isProgrammaticChangeRef = useRef(false);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const handleQueryValidation = (newQuery: string): void => {
|
||||
const handleQueryValidation = useCallback((newQuery: string): void => {
|
||||
try {
|
||||
const validationResponse = validateQuery(newQuery);
|
||||
setValidation(validationResponse);
|
||||
@@ -119,29 +125,67 @@ function QuerySearch({
|
||||
errors: [error as IDetailedError],
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track if the query was changed externally (from queryData) vs internally (user input)
|
||||
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
|
||||
const [lastExternalQuery, setLastExternalQuery] = useState<string>('');
|
||||
const getCurrentQuery = useCallback(
|
||||
(): string => editorRef.current?.state.doc.toString() || '',
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const newQuery = queryData.filter?.expression || '';
|
||||
// Only mark as external change if the query actually changed from external source
|
||||
if (newQuery !== lastExternalQuery) {
|
||||
setQuery(newQuery);
|
||||
setIsExternalQueryChange(true);
|
||||
setLastExternalQuery(newQuery);
|
||||
}
|
||||
}, [queryData.filter?.expression, lastExternalQuery]);
|
||||
const updateEditorValue = useCallback(
|
||||
(value: string, options: { skipOnChange?: boolean } = {}): void => {
|
||||
const view = editorRef.current;
|
||||
if (!view) return;
|
||||
|
||||
// Validate query when it changes externally (from queryData)
|
||||
useEffect(() => {
|
||||
if (isExternalQueryChange && query) {
|
||||
handleQueryValidation(query);
|
||||
setIsExternalQueryChange(false);
|
||||
}
|
||||
}, [isExternalQueryChange, query]);
|
||||
const currentValue = view.state.doc.toString();
|
||||
if (currentValue === value) return;
|
||||
|
||||
if (options.skipOnChange) {
|
||||
isProgrammaticChangeRef.current = true;
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value,
|
||||
},
|
||||
selection: {
|
||||
anchor: value.length,
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditorCreate = useCallback((view: EditorView): void => {
|
||||
editorRef.current = view;
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!isEditorReady) return;
|
||||
|
||||
const newQuery = queryData.filter?.expression || '';
|
||||
const currentQuery = getCurrentQuery();
|
||||
|
||||
/* eslint-disable-next-line sonarjs/no-collapsible-if */
|
||||
if (newQuery !== currentQuery && !isFocused) {
|
||||
// Prevent clearing a non-empty editor when queryData becomes empty temporarily
|
||||
// Only update if newQuery has a value, or if both are empty (initial state)
|
||||
if (newQuery || !currentQuery) {
|
||||
updateEditorValue(newQuery, { skipOnChange: true });
|
||||
|
||||
if (newQuery) {
|
||||
handleQueryValidation(newQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isEditorReady, queryData.filter?.expression, isFocused],
|
||||
);
|
||||
|
||||
const [keySuggestions, setKeySuggestions] = useState<
|
||||
QueryKeyDataSuggestionsProps[] | null
|
||||
@@ -150,7 +194,6 @@ function QuerySearch({
|
||||
const [showExamples] = useState(false);
|
||||
|
||||
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const [
|
||||
isFetchingCompleteValuesList,
|
||||
@@ -159,8 +202,6 @@ function QuerySearch({
|
||||
|
||||
const lastPosRef = useRef<{ line: number; ch: number }>({ line: 0, ch: 0 });
|
||||
|
||||
// Reference to the editor view for programmatic autocompletion
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const lastFetchedKeyRef = useRef<string>('');
|
||||
const lastValueRef = useRef<string>('');
|
||||
@@ -506,6 +547,7 @@ function QuerySearch({
|
||||
|
||||
if (!editorRef.current) {
|
||||
editorRef.current = viewUpdate.view;
|
||||
setIsEditorReady(true);
|
||||
}
|
||||
|
||||
const selection = viewUpdate.view.state.selection.main;
|
||||
@@ -521,7 +563,15 @@ function QuerySearch({
|
||||
const lastPos = lastPosRef.current;
|
||||
|
||||
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
|
||||
setCursorPos(newPos);
|
||||
setCursorPos((lastPos) => {
|
||||
if (newPos.ch !== lastPos.ch && newPos.ch === 0) {
|
||||
Sentry.captureEvent({
|
||||
message: `Cursor jumped to start of line from ${lastPos.ch} to ${newPos.ch}`,
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
return newPos;
|
||||
});
|
||||
lastPosRef.current = newPos;
|
||||
|
||||
if (doc) {
|
||||
@@ -554,16 +604,17 @@ function QuerySearch({
|
||||
}, []);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
if (isProgrammaticChangeRef.current) {
|
||||
isProgrammaticChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(value);
|
||||
// Mark as internal change to avoid triggering external validation
|
||||
setIsExternalQueryChange(false);
|
||||
// Update lastExternalQuery to prevent external validation trigger
|
||||
setLastExternalQuery(value);
|
||||
};
|
||||
|
||||
const handleBlur = (): void => {
|
||||
handleQueryValidation(query);
|
||||
const currentQuery = getCurrentQuery();
|
||||
handleQueryValidation(currentQuery);
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
@@ -582,12 +633,11 @@ function QuerySearch({
|
||||
|
||||
const handleExampleClick = (exampleQuery: string): void => {
|
||||
// If there's an existing query, append the example with AND
|
||||
const newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery;
|
||||
setQuery(newQuery);
|
||||
// Mark as internal change to avoid triggering external validation
|
||||
setIsExternalQueryChange(false);
|
||||
// Update lastExternalQuery to prevent external validation trigger
|
||||
setLastExternalQuery(newQuery);
|
||||
const currentQuery = getCurrentQuery();
|
||||
const newQuery = currentQuery
|
||||
? `${currentQuery} AND ${exampleQuery}`
|
||||
: exampleQuery;
|
||||
updateEditorValue(newQuery);
|
||||
};
|
||||
|
||||
// Helper function to render a badge for the current context mode
|
||||
@@ -622,8 +672,10 @@ function QuerySearch({
|
||||
const word = context.matchBefore(/[a-zA-Z0-9_.:/?&=#%\-\[\]]*/);
|
||||
if (word?.from === word?.to && !context.explicit) return null;
|
||||
|
||||
// Get current query from editor
|
||||
const currentQuery = editorRef.current?.state.doc.toString() || '';
|
||||
// Get the query context at the cursor position
|
||||
const queryContext = getQueryContextAtCursor(query, cursorPos.ch);
|
||||
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch);
|
||||
|
||||
// Define autocomplete options based on the context
|
||||
let options: {
|
||||
@@ -1119,7 +1171,8 @@ function QuerySearch({
|
||||
|
||||
if (queryContext.isInParenthesis) {
|
||||
// Different suggestions based on the context within parenthesis or bracket
|
||||
const curChar = query.charAt(cursorPos.ch - 1) || '';
|
||||
const currentQuery = editorRef.current?.state.doc.toString() || '';
|
||||
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
|
||||
|
||||
if (curChar === '(' || curChar === '[') {
|
||||
// Right after opening parenthesis/bracket
|
||||
@@ -1268,7 +1321,7 @@ function QuerySearch({
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: validation.isValid === false && query ? 40 : 8, // Move left when error shown
|
||||
right: validation.isValid === false && getCurrentQuery() ? 40 : 8, // Move left when error shown
|
||||
cursor: 'help',
|
||||
zIndex: 10,
|
||||
transition: 'right 0.2s ease',
|
||||
@@ -1289,10 +1342,10 @@ function QuerySearch({
|
||||
</Tooltip>
|
||||
|
||||
<CodeMirror
|
||||
value={query}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
onChange={handleChange}
|
||||
onUpdate={handleUpdate}
|
||||
onCreateEditor={handleEditorCreate}
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
hasErrors: validation.errors.length > 0,
|
||||
@@ -1330,7 +1383,7 @@ function QuerySearch({
|
||||
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
onRun(query);
|
||||
onRun(getCurrentQuery());
|
||||
} else {
|
||||
handleRunQuery();
|
||||
}
|
||||
@@ -1356,7 +1409,7 @@ function QuerySearch({
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{query && validation.isValid === false && !isFocused && (
|
||||
{getCurrentQuery() && validation.isValid === false && !isFocused && (
|
||||
<div
|
||||
className={cx('query-status-container', {
|
||||
hasErrors: validation.errors.length > 0,
|
||||
|
||||
@@ -9,7 +9,13 @@ import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearch
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Copy, Ellipsis, Trash } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -20,26 +26,29 @@ import QueryAddOns from './QueryAddOns/QueryAddOns';
|
||||
import QueryAggregation from './QueryAggregation/QueryAggregation';
|
||||
import QuerySearch from './QuerySearch/QuerySearch';
|
||||
|
||||
export const QueryV2 = memo(function QueryV2({
|
||||
ref,
|
||||
index,
|
||||
queryVariant,
|
||||
query,
|
||||
filterConfigs,
|
||||
isListViewPanel = false,
|
||||
showTraceOperator = false,
|
||||
hasTraceOperator = false,
|
||||
version,
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
isMultiQueryAllowed = false,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: QueryProps & {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
}): JSX.Element {
|
||||
export const QueryV2 = forwardRef(function QueryV2(
|
||||
{
|
||||
index,
|
||||
queryVariant,
|
||||
query,
|
||||
filterConfigs,
|
||||
isListViewPanel = false,
|
||||
showTraceOperator = false,
|
||||
hasTraceOperator = false,
|
||||
version,
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
isMultiQueryAllowed = false,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
queriesCount = 1,
|
||||
}: QueryProps & {
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
queriesCount: number;
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
@@ -192,12 +201,16 @@ export const QueryV2 = memo(function QueryV2({
|
||||
icon: <Copy size={14} />,
|
||||
onClick: handleCloneEntity,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
key: 'delete-query',
|
||||
icon: <Trash size={14} />,
|
||||
onClick: handleDeleteQuery,
|
||||
},
|
||||
...(queriesCount && queriesCount > 1
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
key: 'delete-query',
|
||||
icon: <Trash size={14} />,
|
||||
onClick: handleDeleteQuery,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
@@ -289,3 +302,5 @@ export const QueryV2 = memo(function QueryV2({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QueryV2.displayName = 'QueryV2';
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
|
||||
.qb-trace-operator-editor-container {
|
||||
flex: 1;
|
||||
.cm-activeLine > span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.arrow-left {
|
||||
@@ -113,6 +116,8 @@
|
||||
text-overflow: ellipsis;
|
||||
padding: 0px 8px;
|
||||
border-right: 1px solid var(--bg-slate-400);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function TraceOperator({
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<Typography.Text className="label">TRACE OPERATOR</Typography.Text>
|
||||
<Typography.Text className="label">Trace Operator</Typography.Text>
|
||||
<div className="qb-trace-operator-editor-container">
|
||||
<TraceOperatorEditor
|
||||
value={traceOperator?.expression || ''}
|
||||
|
||||
@@ -5,13 +5,85 @@ import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
|
||||
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import QuerySearch from '../QuerySearch/QuerySearch';
|
||||
|
||||
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
|
||||
|
||||
// Mock DOM APIs that CodeMirror needs
|
||||
beforeAll(() => {
|
||||
// Mock getClientRects and getBoundingClientRect for Range objects
|
||||
const mockRect: DOMRect = {
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 100,
|
||||
bottom: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: (): DOMRect => mockRect,
|
||||
} as DOMRect;
|
||||
|
||||
// Create a minimal Range mock with only what CodeMirror actually uses
|
||||
const createMockRange = (): Range => {
|
||||
let startContainer: Node = document.createTextNode('');
|
||||
let endContainer: Node = document.createTextNode('');
|
||||
let startOffset = 0;
|
||||
let endOffset = 0;
|
||||
|
||||
const mockRange = {
|
||||
// CodeMirror uses these for text measurement
|
||||
getClientRects: (): DOMRectList =>
|
||||
(({
|
||||
length: 1,
|
||||
item: (index: number): DOMRect | null => (index === 0 ? mockRect : null),
|
||||
0: mockRect,
|
||||
*[Symbol.iterator](): Generator<DOMRect> {
|
||||
yield mockRect;
|
||||
},
|
||||
} as unknown) as DOMRectList),
|
||||
getBoundingClientRect: (): DOMRect => mockRect,
|
||||
// CodeMirror calls these to set up text ranges
|
||||
setStart: (node: Node, offset: number): void => {
|
||||
startContainer = node;
|
||||
startOffset = offset;
|
||||
},
|
||||
setEnd: (node: Node, offset: number): void => {
|
||||
endContainer = node;
|
||||
endOffset = offset;
|
||||
},
|
||||
// Minimal Range properties (TypeScript requires these)
|
||||
get startContainer(): Node {
|
||||
return startContainer;
|
||||
},
|
||||
get endContainer(): Node {
|
||||
return endContainer;
|
||||
},
|
||||
get startOffset(): number {
|
||||
return startOffset;
|
||||
},
|
||||
get endOffset(): number {
|
||||
return endOffset;
|
||||
},
|
||||
get collapsed(): boolean {
|
||||
return startContainer === endContainer && startOffset === endOffset;
|
||||
},
|
||||
commonAncestorContainer: document.body,
|
||||
};
|
||||
return (mockRange as unknown) as Range;
|
||||
};
|
||||
|
||||
// Mock document.createRange to return a new Range instance each time
|
||||
document.createRange = (): Range => createMockRange();
|
||||
|
||||
// Mock getBoundingClientRect for elements
|
||||
Element.prototype.getBoundingClientRect = (): DOMRect => mockRect;
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
@@ -31,24 +103,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@codemirror/autocomplete', () => ({
|
||||
autocompletion: (): Record<string, unknown> => ({}),
|
||||
closeCompletion: (): boolean => true,
|
||||
completionKeymap: [] as unknown[],
|
||||
startCompletion: (): boolean => true,
|
||||
}));
|
||||
|
||||
jest.mock('@codemirror/lang-javascript', () => ({
|
||||
javascript: (): Record<string, unknown> => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('@uiw/codemirror-theme-copilot', () => ({
|
||||
copilot: {},
|
||||
}));
|
||||
|
||||
jest.mock('@uiw/codemirror-theme-github', () => ({
|
||||
githubLight: {},
|
||||
}));
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
@@ -63,153 +117,19 @@ jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CodeMirror to a simple textarea to make it testable and call onUpdate
|
||||
jest.mock(
|
||||
'@uiw/react-codemirror',
|
||||
(): Record<string, unknown> => {
|
||||
// Minimal EditorView shape used by the component
|
||||
class EditorViewMock {}
|
||||
(EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown);
|
||||
(EditorViewMock as any).lineWrapping = {} as unknown;
|
||||
(EditorViewMock as any).editable = { of: () => ({}) } as unknown;
|
||||
// Note: We're NOT mocking CodeMirror here - using the real component
|
||||
// This provides integration testing with the actual CodeMirror editor
|
||||
|
||||
const keymap = { of: (arr: unknown) => arr } as unknown;
|
||||
const Prec = { highest: (ext: unknown) => ext } as unknown;
|
||||
|
||||
type CodeMirrorProps = {
|
||||
value?: string;
|
||||
onChange?: (v: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
onCreateEditor?: (view: unknown) => unknown;
|
||||
onUpdate?: (arg: {
|
||||
view: {
|
||||
state: {
|
||||
selection: { main: { head: number } };
|
||||
doc: {
|
||||
toString: () => string;
|
||||
lineAt: (
|
||||
_pos: number,
|
||||
) => { number: number; from: number; to: number; text: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
}) => void;
|
||||
'data-testid'?: string;
|
||||
extensions?: unknown[];
|
||||
};
|
||||
|
||||
function CodeMirrorMock({
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
placeholder,
|
||||
onCreateEditor,
|
||||
onUpdate,
|
||||
'data-testid': dataTestId,
|
||||
extensions,
|
||||
}: CodeMirrorProps): JSX.Element {
|
||||
const [localValue, setLocalValue] = React.useState<string>(value ?? '');
|
||||
|
||||
// Provide a fake editor instance
|
||||
React.useEffect(() => {
|
||||
if (onCreateEditor) {
|
||||
onCreateEditor(new EditorViewMock() as any);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Call onUpdate whenever localValue changes to simulate cursor and doc
|
||||
React.useEffect(() => {
|
||||
if (onUpdate) {
|
||||
const text = String(localValue ?? '');
|
||||
const head = text.length;
|
||||
onUpdate({
|
||||
view: {
|
||||
state: {
|
||||
selection: { main: { head } },
|
||||
doc: {
|
||||
toString: (): string => text,
|
||||
lineAt: () => ({
|
||||
number: 1,
|
||||
from: 0,
|
||||
to: text.length,
|
||||
text,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localValue]);
|
||||
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
): void => {
|
||||
const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
|
||||
if (!isModEnter) return;
|
||||
const exts: unknown[] = Array.isArray(extensions) ? extensions : [];
|
||||
const flat: unknown[] = exts.flatMap((x: unknown) =>
|
||||
Array.isArray(x) ? x : [x],
|
||||
);
|
||||
const keyBindings = flat.filter(
|
||||
(x) =>
|
||||
Boolean(x) &&
|
||||
typeof x === 'object' &&
|
||||
'key' in (x as Record<string, unknown>),
|
||||
) as Array<{ key?: string; run?: () => boolean | void }>;
|
||||
keyBindings
|
||||
.filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function')
|
||||
.forEach((b) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
b.run!();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
data-testid={dataTestId || 'query-where-clause-editor'}
|
||||
placeholder={placeholder}
|
||||
value={localValue}
|
||||
onChange={(e): void => {
|
||||
setLocalValue(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: '100%', minHeight: 80 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CodeMirrorMock,
|
||||
EditorView: EditorViewMock,
|
||||
keymap,
|
||||
Prec,
|
||||
};
|
||||
},
|
||||
);
|
||||
const handleRunQueryMock = ((UseQBModule as unknown) as {
|
||||
handleRunQuery: jest.MockedFunction<() => void>;
|
||||
}).handleRunQuery;
|
||||
|
||||
const PLACEHOLDER_TEXT =
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')";
|
||||
const TESTID_EDITOR = 'query-where-clause-editor';
|
||||
const SAMPLE_KEY_TYPING = 'http.';
|
||||
const SAMPLE_VALUE_TYPING_INCOMPLETE = " service.name = '";
|
||||
const SAMPLE_VALUE_TYPING_COMPLETE = " service.name = 'frontend'";
|
||||
const SAMPLE_STATUS_QUERY = " status_code = '200'";
|
||||
const SAMPLE_VALUE_TYPING_INCOMPLETE = "service.name = '";
|
||||
const SAMPLE_VALUE_TYPING_COMPLETE = "service.name = 'frontend'";
|
||||
const SAMPLE_STATUS_QUERY = "http.status_code = '200'";
|
||||
|
||||
describe('QuerySearch', () => {
|
||||
describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
it('renders with placeholder', () => {
|
||||
render(
|
||||
<QuerySearch
|
||||
@@ -219,21 +139,19 @@ describe('QuerySearch', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
|
||||
// CodeMirror renders a contenteditable div, so we check for the container
|
||||
const editorContainer = document.querySelector('.query-where-clause-editor');
|
||||
expect(editorContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches key suggestions when typing a key (debounced)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const advance = (ms: number): void => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: advance,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
// Use real timers for CodeMirror integration tests
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
mockedGetKeys.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
@@ -243,28 +161,33 @@ describe('QuerySearch', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.type(editor, SAMPLE_KEY_TYPING);
|
||||
advance(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
// Wait for CodeMirror to initialize
|
||||
await waitFor(() => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the CodeMirror editor contenteditable element
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
|
||||
// Focus and type into the editor
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_KEY_TYPING);
|
||||
|
||||
// Wait for debounced API call (300ms debounce + some buffer)
|
||||
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('fetches value suggestions when editing value context', async () => {
|
||||
jest.useFakeTimers();
|
||||
const advance = (ms: number): void => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: advance,
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
// Use real timers for CodeMirror integration tests
|
||||
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
mockedGetValues.mockClear();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
@@ -274,21 +197,28 @@ describe('QuerySearch', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||
advance(1000);
|
||||
|
||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
// Wait for CodeMirror to initialize
|
||||
await waitFor(() => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||
|
||||
// Wait for debounced API call (300ms debounce + some buffer)
|
||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('fetches key suggestions on mount for LOGS', async () => {
|
||||
jest.useFakeTimers();
|
||||
// Use real timers for CodeMirror integration tests
|
||||
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
mockedGetKeysOnMount.mockClear();
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
@@ -298,17 +228,15 @@ describe('QuerySearch', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
// Wait for debounced API call (300ms debounce + some buffer)
|
||||
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
|
||||
timeout: 3000,
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
const lastArgs = mockedGetKeysOnMount.mock.calls[
|
||||
mockedGetKeysOnMount.mock.calls.length - 1
|
||||
]?.[0] as { signal: unknown; searchText: string };
|
||||
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls provided onRun on Mod-Enter', async () => {
|
||||
@@ -324,12 +252,26 @@ describe('QuerySearch', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
// Wait for CodeMirror to initialize
|
||||
await waitFor(() => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_STATUS_QUERY);
|
||||
await user.keyboard('{Meta>}{Enter}{/Meta}');
|
||||
|
||||
await waitFor(() => expect(onRun).toHaveBeenCalled());
|
||||
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||
fireEvent.keyDown(editor, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
[modKey]: true,
|
||||
keyCode: 13,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
|
||||
@@ -348,11 +290,62 @@ describe('QuerySearch', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
||||
// Wait for CodeMirror to initialize
|
||||
await waitFor(() => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await user.click(editor);
|
||||
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
|
||||
await user.keyboard('{Meta>}{Enter}{/Meta}');
|
||||
|
||||
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
|
||||
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||
fireEvent.keyDown(editor, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
[modKey]: true,
|
||||
keyCode: 13,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
|
||||
const testExpression =
|
||||
"http.status_code >= 500 AND service.name = 'frontend'";
|
||||
const queryDataWithExpression = {
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filter: {
|
||||
expression: testExpression,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={queryDataWithExpression}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for CodeMirror to initialize and the expression to be set
|
||||
await waitFor(
|
||||
() => {
|
||||
// CodeMirror stores content in .cm-content, check the text content
|
||||
const editorContent = document.querySelector(
|
||||
CM_EDITOR_SELECTOR,
|
||||
) as HTMLElement;
|
||||
expect(editorContent).toBeInTheDocument();
|
||||
// CodeMirror may render the text in multiple ways, check if it contains our expression
|
||||
const textContent = editorContent.textContent || '';
|
||||
expect(textContent).toContain('http.status_code');
|
||||
expect(textContent).toContain('service.name');
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ const themeColors = {
|
||||
cyan: '#00FFFF',
|
||||
},
|
||||
chartcolors: {
|
||||
robin: '#3F5ECC',
|
||||
radicalRed: '#FF1A66',
|
||||
dodgerBlue: '#2F80ED',
|
||||
mediumOrchid: '#BB6BD9',
|
||||
seaBuckthorn: '#F2994A',
|
||||
@@ -58,7 +58,7 @@ const themeColors = {
|
||||
oliveDrab: '#66991A',
|
||||
lavenderRose: '#FF99E6',
|
||||
electricLime: '#CCFF1A',
|
||||
radicalRed: '#FF1A66',
|
||||
robin: '#3F5ECC',
|
||||
harleyOrange: '#E6331A',
|
||||
turquoise: '#33FFCC',
|
||||
gladeGreen: '#66994D',
|
||||
@@ -80,7 +80,7 @@ const themeColors = {
|
||||
maroon: '#800000',
|
||||
navy: '#000080',
|
||||
aquamarine: '#7FFFD4',
|
||||
gold: '#FFD700',
|
||||
darkSeaGreen: '#8FBC8F',
|
||||
gray: '#808080',
|
||||
skyBlue: '#87CEEB',
|
||||
indigo: '#4B0082',
|
||||
@@ -105,7 +105,7 @@ const themeColors = {
|
||||
lawnGreen: '#7CFC00',
|
||||
mediumSeaGreen: '#3CB371',
|
||||
lightCoral: '#F08080',
|
||||
darkSeaGreen: '#8FBC8F',
|
||||
gold: '#FFD700',
|
||||
sandyBrown: '#F4A460',
|
||||
darkKhaki: '#BDB76B',
|
||||
cornflowerBlue: '#6495ED',
|
||||
@@ -113,7 +113,7 @@ const themeColors = {
|
||||
paleGreen: '#98FB98',
|
||||
},
|
||||
lightModeColor: {
|
||||
robin: '#3F5ECC',
|
||||
radicalRed: '#FF1A66',
|
||||
dodgerBlueDark: '#0C6EED',
|
||||
steelgrey: '#2f4b7c',
|
||||
steelpurple: '#665191',
|
||||
@@ -143,7 +143,7 @@ const themeColors = {
|
||||
oliveDrab: '#66991A',
|
||||
lavenderRoseDark: '#F024BD',
|
||||
electricLimeDark: '#84A800',
|
||||
radicalRed: '#FF1A66',
|
||||
robin: '#3F5ECC',
|
||||
harleyOrange: '#E6331A',
|
||||
gladeGreen: '#66994D',
|
||||
hemlock: '#66664D',
|
||||
@@ -181,7 +181,7 @@ const themeColors = {
|
||||
darkOrchid: '#9932CC',
|
||||
mediumSeaGreenDark: '#109E50',
|
||||
lightCoralDark: '#F85959',
|
||||
darkSeaGreenDark: '#509F50',
|
||||
gold: '#FFD700',
|
||||
sandyBrownDark: '#D97117',
|
||||
darkKhakiDark: '#99900A',
|
||||
cornflowerBlueDark: '#3371E6',
|
||||
|
||||
@@ -3,3 +3,6 @@ export const THRESHOLD_TAB_TOOLTIP =
|
||||
|
||||
export const ANOMALY_TAB_TOOLTIP =
|
||||
'An alert is triggered whenever the metric deviates from an expected pattern.';
|
||||
|
||||
export const ROUTING_POLICIES_ROUTE =
|
||||
'/alerts?tab=Configuration&subTab=routing-policies';
|
||||
|
||||
@@ -289,6 +289,21 @@
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
padding: 8px 16px;
|
||||
|
||||
.routing-policies-info-banner-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.view-routing-policies-button {
|
||||
color: var(--bg-robin-500);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { ROUTING_POLICIES_ROUTE } from './constants';
|
||||
import { RoutingPolicyBannerProps } from './types';
|
||||
|
||||
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
@@ -400,16 +402,27 @@ export function RoutingPolicyBanner({
|
||||
<Typography.Text>
|
||||
Use <strong>Routing Policies</strong> for dynamic routing
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
data-testid="routing-policies-switch"
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="routing-policies-info-banner-right">
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
data-testid="routing-policies-switch"
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
href={ROUTING_POLICIES_ROUTE}
|
||||
type="link"
|
||||
className="view-routing-policies-button"
|
||||
data-testid="view-routing-policies-button"
|
||||
>
|
||||
View Routing Policies
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: #888;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from '@signozhq/sonner';
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Check, Send, X } from 'lucide-react';
|
||||
import { Check, Loader, Send, X } from 'lucide-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
@@ -150,7 +150,11 @@ function Footer(): JSX.Element {
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
>
|
||||
<Check size={14} />
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader size={14} />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)}
|
||||
<Typography.Text>Save Alert Rule</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
@@ -158,7 +162,13 @@ function Footer(): JSX.Element {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [alertValidationMessage, disableButtons, handleSaveAlert]);
|
||||
}, [
|
||||
alertValidationMessage,
|
||||
disableButtons,
|
||||
handleSaveAlert,
|
||||
isCreatingAlertRule,
|
||||
isUpdatingAlertRule,
|
||||
]);
|
||||
|
||||
const testAlertButton = useMemo(() => {
|
||||
let button = (
|
||||
@@ -167,7 +177,7 @@ function Footer(): JSX.Element {
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
>
|
||||
<Send size={14} />
|
||||
{isTestingAlertRule ? <Loader size={14} /> : <Send size={14} />}
|
||||
<Typography.Text>Test Notification</Typography.Text>
|
||||
</Button>
|
||||
);
|
||||
@@ -175,7 +185,12 @@ function Footer(): JSX.Element {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
}
|
||||
return button;
|
||||
}, [alertValidationMessage, disableButtons, handleTestNotification]);
|
||||
}, [
|
||||
alertValidationMessage,
|
||||
disableButtons,
|
||||
handleTestNotification,
|
||||
isTestingAlertRule,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="create-alert-v2-footer">
|
||||
|
||||
@@ -67,6 +67,10 @@ const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule';
|
||||
const TEST_NOTIFICATION_TEXT = 'Test Notification';
|
||||
const DISCARD_TEXT = 'Discard';
|
||||
|
||||
const LOADER_ICON_SELECTOR = 'svg.lucide-loader';
|
||||
const CHECK_ICON_SELECTOR = 'svg.lucide-check';
|
||||
const PLAY_ICON_SELECTOR = 'svg.lucide-play';
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
@@ -245,4 +249,61 @@ describe('Footer', () => {
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should show loader icon on test notification button when testing alert rule', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isTestingAlertRule: true,
|
||||
});
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// When testing alert rule, the play icon is replaced with a loader icon
|
||||
const playIconForTestNotificationButton = container.querySelector(
|
||||
PLAY_ICON_SELECTOR,
|
||||
);
|
||||
expect(playIconForTestNotificationButton).not.toBeInTheDocument();
|
||||
|
||||
const loaderIconForTestNotificationButton = container.querySelector(
|
||||
LOADER_ICON_SELECTOR,
|
||||
);
|
||||
expect(loaderIconForTestNotificationButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show check icon on save alert rule button when updating alert rule', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isUpdatingAlertRule: true,
|
||||
});
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// When updating alert rule, the check icon is replaced with a loader icon
|
||||
const checkIconForSaveAlertRuleButton = container.querySelector(
|
||||
CHECK_ICON_SELECTOR,
|
||||
);
|
||||
expect(checkIconForSaveAlertRuleButton).not.toBeInTheDocument();
|
||||
|
||||
const loaderIconForSaveAlertRuleButton = container.querySelector(
|
||||
LOADER_ICON_SELECTOR,
|
||||
);
|
||||
expect(loaderIconForSaveAlertRuleButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show check icon on save alert rule button when creating alert rule', () => {
|
||||
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||
...mockAlertContextState,
|
||||
isCreatingAlertRule: true,
|
||||
});
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// When creating alert rule, the check icon is replaced with a loader icon
|
||||
const checkIconForSaveAlertRuleButton = container.querySelector(
|
||||
CHECK_ICON_SELECTOR,
|
||||
);
|
||||
expect(checkIconForSaveAlertRuleButton).not.toBeInTheDocument();
|
||||
|
||||
const loaderIconForSaveAlertRuleButton = container.querySelector(
|
||||
LOADER_ICON_SELECTOR,
|
||||
);
|
||||
expect(loaderIconForSaveAlertRuleButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.query-section-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
margin-left: 8px;
|
||||
margin-top: 24px;
|
||||
|
||||
.query-section-query-actions {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
|
||||
import { Col, Typography } from 'antd';
|
||||
import { StyledCol, StyledRow } from 'components/Styled';
|
||||
import { IIntervalUnit } from 'container/TraceDetail/utils';
|
||||
import {
|
||||
IIntervalUnit,
|
||||
SPAN_DETAILS_LEFT_COL_WIDTH,
|
||||
} from 'container/TraceDetail/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
|
||||
import {
|
||||
Dispatch,
|
||||
MouseEventHandler,
|
||||
|
||||
@@ -170,7 +170,8 @@ describe('MultiIngestionSettings Page', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to create alert for logs with size threshold', async () => {
|
||||
// skipping the flaky test
|
||||
it.skip('navigates to create alert for logs with size threshold', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a logs daily size limit so the alert button is visible
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -48,7 +47,6 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
traceId,
|
||||
selectedSpanId: firstSpanAtFetchLevel,
|
||||
});
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// get the current state of trace flamegraph based on the API lifecycle
|
||||
const traceFlamegraphState = useMemo(() => {
|
||||
@@ -124,6 +122,8 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
traceId,
|
||||
]);
|
||||
|
||||
const spread = useMemo(() => endTime - startTime, [endTime, startTime]);
|
||||
|
||||
return (
|
||||
<div className="flamegraph">
|
||||
<div
|
||||
@@ -132,36 +132,40 @@ function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
|
||||
>
|
||||
<div className="exec-time-service">% exec time</div>
|
||||
<div className="stats">
|
||||
{Object.keys(serviceExecTime).map((service) => {
|
||||
const spread = endTime - startTime;
|
||||
const value = (serviceExecTime[service] * 100) / spread;
|
||||
const color = generateColor(
|
||||
service,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
return (
|
||||
<div key={service} className="value-row">
|
||||
<section className="service-name">
|
||||
<div className="square-box" style={{ backgroundColor: color }} />
|
||||
<Tooltip title={service}>
|
||||
<Typography.Text className="service-text" ellipsis>
|
||||
{service}
|
||||
{Object.keys(serviceExecTime)
|
||||
.sort((a, b) => {
|
||||
if (spread <= 0) return 0;
|
||||
const aValue = (serviceExecTime[a] * 100) / spread;
|
||||
const bValue = (serviceExecTime[b] * 100) / spread;
|
||||
return bValue - aValue;
|
||||
})
|
||||
.map((service) => {
|
||||
const value =
|
||||
spread <= 0 ? 0 : (serviceExecTime[service] * 100) / spread;
|
||||
const color = generateColor(service, themeColors.traceDetailColors);
|
||||
return (
|
||||
<div key={service} className="value-row">
|
||||
<section className="service-name">
|
||||
<div className="square-box" style={{ backgroundColor: color }} />
|
||||
<Tooltip title={service}>
|
||||
<Typography.Text className="service-text" ellipsis>
|
||||
{service}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="progress-service">
|
||||
<Progress
|
||||
percent={parseFloat(value.toFixed(2))}
|
||||
className="service-progress-indicator"
|
||||
showInfo={false}
|
||||
/>
|
||||
<Typography.Text className="percent-value">
|
||||
{parseFloat(value.toFixed(2))}%
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="progress-service">
|
||||
<Progress
|
||||
percent={parseFloat(value.toFixed(2))}
|
||||
className="service-progress-indicator"
|
||||
showInfo={false}
|
||||
/>
|
||||
<Typography.Text className="percent-value">
|
||||
{parseFloat(value.toFixed(2))}%
|
||||
</Typography.Text>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
useGetAllDowntimeSchedules,
|
||||
} from 'api/plannedDowntime/getAllDowntimeSchedules';
|
||||
import dayjs from 'dayjs';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
@@ -36,6 +39,8 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [initialValues, setInitialValues] = useState<
|
||||
Partial<DowntimeSchedules & { editMode: boolean }>
|
||||
@@ -57,16 +62,31 @@ export function PlannedDowntime(): JSX.Element {
|
||||
}
|
||||
}, [form, isOpen]);
|
||||
|
||||
const [searchValue, setSearchValue] = React.useState<string | number>('');
|
||||
const [searchValue, setSearchValue] = React.useState<string | number>(
|
||||
urlQuery.get('search') || '',
|
||||
);
|
||||
const [deleteData, setDeleteData] = useState<{ id: number; name: string }>();
|
||||
const [isEditMode, setEditMode] = useState<boolean>(false);
|
||||
|
||||
const updateUrlWithSearch = useDebouncedFn((value) => {
|
||||
const searchValue = value as string;
|
||||
if (searchValue) {
|
||||
urlQuery.set('search', searchValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
}
|
||||
const url = `/alerts?${urlQuery.toString()}`;
|
||||
history.replace(url);
|
||||
}, 300);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setSearchValue(e.target.value);
|
||||
updateUrlWithSearch(e.target.value);
|
||||
};
|
||||
|
||||
const clearSearch = (): void => {
|
||||
setSearchValue('');
|
||||
updateUrlWithSearch('');
|
||||
};
|
||||
|
||||
// Delete Downtime Schedule
|
||||
|
||||
@@ -1,10 +1,110 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { PayloadProps } from 'api/plannedDowntime/getAllDowntimeSchedules';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import {
|
||||
mockLocation,
|
||||
mockQueryParams,
|
||||
} from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntime } from '../PlannedDowntime';
|
||||
import { buildSchedule, createMockDowntime } from './testUtils';
|
||||
|
||||
const SEARCH_PLACEHOLDER = 'Search for a planned downtime...';
|
||||
|
||||
const MOCK_DOWNTIME_1_NAME = 'Mock Downtime 1';
|
||||
const MOCK_DOWNTIME_2_NAME = 'Mock Downtime 2';
|
||||
const MOCK_DOWNTIME_3_NAME = 'Mock Downtime 3';
|
||||
const MOCK_DATE_1 = '2024-01-01';
|
||||
const MOCK_DATE_2 = '2024-01-02';
|
||||
const MOCK_DATE_3 = '2024-01-03';
|
||||
|
||||
const MOCK_DOWNTIME_1 = createMockDowntime({
|
||||
id: 1,
|
||||
name: MOCK_DOWNTIME_1_NAME,
|
||||
createdAt: MOCK_DATE_1,
|
||||
updatedAt: MOCK_DATE_1,
|
||||
schedule: buildSchedule({ startTime: MOCK_DATE_1, timezone: 'UTC' }),
|
||||
alertIds: [],
|
||||
});
|
||||
|
||||
const MOCK_DOWNTIME_2 = createMockDowntime({
|
||||
id: 2,
|
||||
name: MOCK_DOWNTIME_2_NAME,
|
||||
createdAt: MOCK_DATE_2,
|
||||
updatedAt: MOCK_DATE_2,
|
||||
schedule: buildSchedule({ startTime: MOCK_DATE_2, timezone: 'UTC' }),
|
||||
alertIds: [],
|
||||
});
|
||||
|
||||
const MOCK_DOWNTIME_3 = createMockDowntime({
|
||||
id: 3,
|
||||
name: MOCK_DOWNTIME_3_NAME,
|
||||
createdAt: MOCK_DATE_3,
|
||||
updatedAt: MOCK_DATE_3,
|
||||
schedule: buildSchedule({ startTime: MOCK_DATE_3, timezone: 'UTC' }),
|
||||
alertIds: [],
|
||||
});
|
||||
|
||||
const MOCK_DOWNTIME_RESPONSE: Partial<AxiosResponse<PayloadProps>> = {
|
||||
data: {
|
||||
data: [MOCK_DOWNTIME_1, MOCK_DOWNTIME_2, MOCK_DOWNTIME_3],
|
||||
},
|
||||
};
|
||||
|
||||
type DowntimeQueryResult = UseQueryResult<
|
||||
AxiosResponse<PayloadProps>,
|
||||
AxiosError
|
||||
>;
|
||||
|
||||
const mockDowntimeQueryResult: Partial<DowntimeQueryResult> = {
|
||||
data: MOCK_DOWNTIME_RESPONSE as AxiosResponse<PayloadProps>,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUseLocation = jest.fn().mockReturnValue({
|
||||
pathname: '/alerts',
|
||||
});
|
||||
let mockUrlQuery: URLSearchParams;
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): void => mockUseLocation(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/plannedDowntime/getAllDowntimeSchedules', () => ({
|
||||
useGetAllDowntimeSchedules: (): DowntimeQueryResult =>
|
||||
mockDowntimeQueryResult as DowntimeQueryResult,
|
||||
}));
|
||||
jest.mock('api/alerts/getAll', () => ({
|
||||
__esModule: true,
|
||||
default: (): Promise<{ payload: [] }> => Promise.resolve({ payload: [] }),
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQuery = mockQueryParams({});
|
||||
mockLocation('/alerts');
|
||||
});
|
||||
|
||||
it('renders the PlannedDowntime component properly', () => {
|
||||
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
|
||||
|
||||
@@ -17,9 +117,7 @@ describe('PlannedDowntime Component', () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if search input is rendered
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search for a planned downtime...'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||
|
||||
// Check if "New downtime" button is enabled for ADMIN
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
@@ -41,4 +139,75 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
expect(newDowntimeButton).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should load with search term from URL query params', () => {
|
||||
const searchTerm = 'existing search';
|
||||
mockUrlQuery = mockQueryParams({ search: searchTerm });
|
||||
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe(searchTerm);
|
||||
});
|
||||
|
||||
it('should initialize with empty search when no search param is in URL', () => {
|
||||
mockUrlQuery = mockQueryParams({});
|
||||
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('should display all downtime schedules when no search term is entered', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DOWNTIME_2_NAME)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DOWNTIME_3_NAME)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter downtime schedules by name when searching', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: MOCK_DOWNTIME_1_NAME } });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_2_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter downtime schedules with partial name match', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_1_NAME)).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '2' } });
|
||||
|
||||
expect(screen.getByText(MOCK_DOWNTIME_2_NAME)).toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_1_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show no results when search term matches nothing', async () => {
|
||||
render(<PlannedDowntime />, {}, { role: USER_ROLES.ADMIN });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'NonExistentDowntime' } });
|
||||
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_1_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_2_NAME)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(MOCK_DOWNTIME_3_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
29
frontend/src/container/PlannedDowntime/__test__/testUtils.ts
Normal file
29
frontend/src/container/PlannedDowntime/__test__/testUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DowntimeSchedules } from 'api/plannedDowntime/getAllDowntimeSchedules';
|
||||
|
||||
export const buildSchedule = (
|
||||
schedule: Partial<DowntimeSchedules['schedule']>,
|
||||
): DowntimeSchedules['schedule'] => ({
|
||||
timezone: schedule?.timezone ?? null,
|
||||
startTime: schedule?.startTime ?? null,
|
||||
endTime: schedule?.endTime ?? null,
|
||||
recurrence: schedule?.recurrence ?? null,
|
||||
});
|
||||
|
||||
export const createMockDowntime = (
|
||||
overrides: Partial<DowntimeSchedules>,
|
||||
): DowntimeSchedules => ({
|
||||
id: overrides.id ?? 0,
|
||||
name: overrides.name ?? null,
|
||||
description: overrides.description ?? null,
|
||||
schedule: buildSchedule({
|
||||
timezone: 'UTC',
|
||||
startTime: '2024-01-01',
|
||||
...overrides.schedule,
|
||||
}),
|
||||
alertIds: overrides.alertIds ?? null,
|
||||
createdAt: overrides.createdAt ?? null,
|
||||
createdBy: overrides.createdBy ?? null,
|
||||
updatedAt: overrides.updatedAt ?? null,
|
||||
updatedBy: overrides.updatedBy ?? null,
|
||||
kind: overrides.kind ?? null,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Modal, Typography } from 'antd';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import { Loader, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { DeleteRoutingPolicyProps } from './types';
|
||||
|
||||
@@ -9,6 +9,12 @@ function DeleteRoutingPolicy({
|
||||
routingPolicy,
|
||||
isDeletingRoutingPolicy,
|
||||
}: DeleteRoutingPolicyProps): JSX.Element {
|
||||
const deleteButtonIcon = isDeletingRoutingPolicy ? (
|
||||
<Loader size={16} />
|
||||
) : (
|
||||
<Trash2 size={16} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="delete-policy-modal"
|
||||
@@ -28,7 +34,8 @@ function DeleteRoutingPolicy({
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
icon={<Trash2 size={16} />}
|
||||
type="primary"
|
||||
icon={deleteButtonIcon}
|
||||
onClick={handleDelete}
|
||||
className="delete-btn"
|
||||
disabled={isDeletingRoutingPolicy}
|
||||
@@ -38,7 +45,9 @@ function DeleteRoutingPolicy({
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="delete-text">
|
||||
{`Are you sure you want to delete ${routingPolicy?.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`}
|
||||
Are you sure you want to delete <strong>{routingPolicy?.name}</strong>{' '}
|
||||
routing policy? Deleting a routing policy is irreversible and cannot be
|
||||
undone.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,9 @@ function RoutingPolicies(): JSX.Element {
|
||||
selectedRoutingPolicy,
|
||||
routingPoliciesData,
|
||||
isLoadingRoutingPolicies,
|
||||
isFetchingRoutingPolicies,
|
||||
isErrorRoutingPolicies,
|
||||
refetchRoutingPolicies,
|
||||
// Channels
|
||||
channels,
|
||||
isLoadingChannels,
|
||||
@@ -84,6 +86,8 @@ function RoutingPolicies(): JSX.Element {
|
||||
<br />
|
||||
<RoutingPolicyList
|
||||
routingPolicies={routingPoliciesData}
|
||||
refetchRoutingPolicies={refetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={isFetchingRoutingPolicies}
|
||||
isRoutingPoliciesLoading={isLoadingRoutingPolicies}
|
||||
isRoutingPoliciesError={isErrorRoutingPolicies}
|
||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useForm } from 'antd/lib/form/Form';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
|
||||
import { Check, Loader, X } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
@@ -47,6 +48,12 @@ function RoutingPolicyDetails({
|
||||
return INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE;
|
||||
}, [routingPolicy, mode]);
|
||||
|
||||
const saveButtonIcon = isPolicyDetailsModalActionLoading ? (
|
||||
<Loader size={16} />
|
||||
) : (
|
||||
<Check size={16} />
|
||||
);
|
||||
|
||||
const modalTitle =
|
||||
mode === 'edit' ? 'Edit routing policy' : 'Create routing policy';
|
||||
|
||||
@@ -188,10 +195,15 @@ function RoutingPolicyDetails({
|
||||
</div>
|
||||
</div>
|
||||
<Flex className="create-policy-footer" justify="space-between">
|
||||
<Button onClick={closeModal} disabled={isPolicyDetailsModalActionLoading}>
|
||||
<Button
|
||||
icon={<X size={16} />}
|
||||
onClick={closeModal}
|
||||
disabled={isPolicyDetailsModalActionLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
icon={saveButtonIcon}
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isPolicyDetailsModalActionLoading}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Table, TableProps, Typography } from 'antd';
|
||||
import { Button, Table, TableProps, Typography } from 'antd';
|
||||
import { RotateCw } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import RoutingPolicyListItem from './RoutingPolicyListItem';
|
||||
@@ -6,6 +7,8 @@ import { RoutingPolicy, RoutingPolicyListProps } from './types';
|
||||
|
||||
function RoutingPolicyList({
|
||||
routingPolicies,
|
||||
refetchRoutingPolicies,
|
||||
isRoutingPoliciesFetching,
|
||||
isRoutingPoliciesLoading,
|
||||
isRoutingPoliciesError,
|
||||
handlePolicyDetailsModalOpen,
|
||||
@@ -26,11 +29,14 @@ function RoutingPolicyList({
|
||||
},
|
||||
];
|
||||
|
||||
const showLoading = isRoutingPoliciesLoading || isRoutingPoliciesFetching;
|
||||
const showError = !showLoading && isRoutingPoliciesError;
|
||||
|
||||
/* eslint-disable no-nested-ternary */
|
||||
const localeEmptyState = useMemo(
|
||||
() => (
|
||||
<div className="no-routing-policies-message-container">
|
||||
{isRoutingPoliciesError ? (
|
||||
{showError ? (
|
||||
<img src="/Icons/awwSnap.svg" alt="aww-snap" className="error-state-svg" />
|
||||
) : (
|
||||
<img
|
||||
@@ -39,10 +45,15 @@ function RoutingPolicyList({
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
)}
|
||||
{isRoutingPoliciesError ? (
|
||||
<Typography.Text>
|
||||
Something went wrong while fetching routing policies.
|
||||
</Typography.Text>
|
||||
{showError ? (
|
||||
<div className="error-state">
|
||||
<Typography.Text>
|
||||
Something went wrong while fetching routing policies.
|
||||
</Typography.Text>
|
||||
<Button icon={<RotateCw size={14} />} onClick={refetchRoutingPolicies}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : hasSearchTerm ? (
|
||||
<Typography.Text>No matching routing policies found.</Typography.Text>
|
||||
) : (
|
||||
@@ -59,7 +70,7 @@ function RoutingPolicyList({
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[isRoutingPoliciesError, hasSearchTerm],
|
||||
[showError, hasSearchTerm, refetchRoutingPolicies],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -68,7 +79,7 @@ function RoutingPolicyList({
|
||||
className="routing-policies-table"
|
||||
bordered={false}
|
||||
dataSource={routingPolicies}
|
||||
loading={isRoutingPoliciesLoading}
|
||||
loading={showLoading}
|
||||
showHeader={false}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
@@ -77,7 +88,7 @@ function RoutingPolicyList({
|
||||
hideOnSinglePage: true,
|
||||
}}
|
||||
locale={{
|
||||
emptyText: isRoutingPoliciesLoading ? null : localeEmptyState,
|
||||
emptyText: showLoading ? null : localeEmptyState,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Collapse, Flex, Tag, Typography } from 'antd';
|
||||
import { Button, Collapse, Flex, Tag, Typography } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { PenLine, Trash2 } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -22,29 +22,44 @@ function PolicyListItemHeader({
|
||||
const isEditEnabled = user?.role !== USER_ROLES.VIEWER;
|
||||
|
||||
return (
|
||||
<Flex className="policy-list-item-header" justify="space-between">
|
||||
<Typography>{name}</Typography>
|
||||
|
||||
<Flex
|
||||
className="policy-list-item-header"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Typography.Text
|
||||
className="policy-list-item-header-title"
|
||||
ellipsis={{ tooltip: name }}
|
||||
>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
{isEditEnabled && (
|
||||
<div className="action-btn">
|
||||
<PenLine
|
||||
size={14}
|
||||
<Button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
data-testid="edit-routing-policy"
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<PenLine size={14} data-testid="edit-routing-policy" />}
|
||||
/>
|
||||
<Trash2
|
||||
size={14}
|
||||
color={Color.BG_CHERRY_500}
|
||||
<Button
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
data-testid="delete-routing-policy"
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={
|
||||
<Trash2
|
||||
size={14}
|
||||
color={Color.BG_CHERRY_500}
|
||||
data-testid="delete-routing-policy"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,11 +106,15 @@ function PolicyListItemContent({
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Expression</Typography>
|
||||
<Typography>{routingPolicy.expression}</Typography>
|
||||
<Typography.Text ellipsis={{ tooltip: routingPolicy.expression || '-' }}>
|
||||
{routingPolicy.expression || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Description</Typography>
|
||||
<Typography>{routingPolicy.description || '-'}</Typography>
|
||||
<Typography.Text ellipsis={{ tooltip: routingPolicy.description || '-' }}>
|
||||
{routingPolicy.description || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="policy-list-item-content-row">
|
||||
<Typography>Channels</Typography>
|
||||
|
||||
@@ -23,9 +23,13 @@ describe('DeleteRoutingPolicy', () => {
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: DELETE_BUTTON_TEXT }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Are you sure you want to delete/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Are you sure you want to delete ${mockRoutingPolicy.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`,
|
||||
/Deleting a routing policy is irreversible and cannot be undone\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -7,12 +7,34 @@ import {
|
||||
getAppContextMockState,
|
||||
getUseRoutingPoliciesMockData,
|
||||
MOCK_ROUTING_POLICY_1,
|
||||
mockLocation,
|
||||
mockQueryParams,
|
||||
} from './testUtils';
|
||||
|
||||
const ROUTING_POLICY_DETAILS_TEST_ID = 'routing-policy-details';
|
||||
const SEARCH_PLACEHOLDER = 'Search for a routing policy...';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockQueryParams({}),
|
||||
}));
|
||||
|
||||
const mockHistoryReplace = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: (): any => ({
|
||||
replace: mockHistoryReplace,
|
||||
}),
|
||||
useLocation: (): any => ({
|
||||
pathname: '/alerts',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../RoutingPolicyList', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => (
|
||||
@@ -42,15 +64,19 @@ jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
);
|
||||
|
||||
describe('RoutingPolicies', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockQueryParams({});
|
||||
mockLocation('/alerts');
|
||||
});
|
||||
|
||||
it('should render components properly', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create and manage routing policies.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search for a routing policy...'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /New routing policy/ }),
|
||||
).toBeInTheDocument();
|
||||
@@ -80,9 +106,7 @@ describe('RoutingPolicies', () => {
|
||||
|
||||
it('filters routing policies by search term', () => {
|
||||
render(<RoutingPolicies />);
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Search for a routing policy...',
|
||||
);
|
||||
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
|
||||
fireEvent.change(searchInput, {
|
||||
target: { value: MOCK_ROUTING_POLICY_1.name },
|
||||
});
|
||||
@@ -123,4 +147,37 @@ describe('RoutingPolicies', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(screen.getByTestId('delete-routing-policy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should load with search term from URL query params', () => {
|
||||
const searchTerm = 'existing search';
|
||||
mockQueryParams({ search: searchTerm });
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
searchTerm,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<RoutingPolicies />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe(searchTerm);
|
||||
});
|
||||
|
||||
it('should initialize with empty search when no search param is in URL', () => {
|
||||
mockQueryParams({});
|
||||
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
|
||||
getUseRoutingPoliciesMockData({
|
||||
searchTerm: '',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<RoutingPolicies />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
SEARCH_PLACEHOLDER,
|
||||
) as HTMLInputElement;
|
||||
expect(searchInput.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import RoutingPoliciesList from '../RoutingPolicyList';
|
||||
import { RoutingPolicyListItemProps } from '../types';
|
||||
@@ -7,6 +7,7 @@ import { getUseRoutingPoliciesMockData } from './testUtils';
|
||||
const useRoutingPolicesMockData = getUseRoutingPoliciesMockData();
|
||||
const mockHandlePolicyDetailsModalOpen = jest.fn();
|
||||
const mockHandleDeleteModalOpen = jest.fn();
|
||||
const mockRefetchRoutingPolicies = jest.fn();
|
||||
|
||||
jest.mock('../RoutingPolicyListItem', () => ({
|
||||
__esModule: true,
|
||||
@@ -29,6 +30,8 @@ describe('RoutingPoliciesList', () => {
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -53,6 +56,27 @@ describe('RoutingPoliciesList', () => {
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
// Check for loading spinner by class name
|
||||
expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument();
|
||||
// Check that the table is in loading state (blurred)
|
||||
expect(document.querySelector('.ant-spin-blur')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state when data is being fetched', () => {
|
||||
render(
|
||||
<RoutingPoliciesList
|
||||
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
|
||||
isRoutingPoliciesLoading={false}
|
||||
isRoutingPoliciesError={false}
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching
|
||||
/>,
|
||||
);
|
||||
// Check for loading spinner by class name
|
||||
@@ -70,11 +94,18 @@ describe('RoutingPoliciesList', () => {
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Something went wrong while fetching routing policies.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: 'Retry' });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
fireEvent.click(retryButton);
|
||||
expect(mockRefetchRoutingPolicies).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
@@ -86,6 +117,8 @@ describe('RoutingPoliciesList', () => {
|
||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||
hasSearchTerm={false}
|
||||
refetchRoutingPolicies={mockRefetchRoutingPolicies}
|
||||
isRoutingPoliciesFetching={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('No routing policies yet,')).toBeInTheDocument();
|
||||
|
||||
@@ -260,6 +260,11 @@ describe('RoutingPolicyDetails', () => {
|
||||
name: new RegExp(SAVE_BUTTON_TEXT, 'i'),
|
||||
});
|
||||
expect(saveButton).toBeDisabled();
|
||||
expect(saveButton.querySelector('svg')).toBeInTheDocument();
|
||||
expect(saveButton.querySelector('svg')).toHaveAttribute(
|
||||
'data-icon',
|
||||
'loading',
|
||||
);
|
||||
});
|
||||
|
||||
it('submit should not be called when inputs are invalid', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
|
||||
import { IAppContext, IUser } from 'providers/App/types';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
@@ -71,6 +72,8 @@ export function getUseRoutingPoliciesMockData(
|
||||
isPolicyDetailsModalActionLoading: false,
|
||||
isErrorChannels: false,
|
||||
refreshChannels: jest.fn(),
|
||||
isFetchingRoutingPolicies: false,
|
||||
refetchRoutingPolicies: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -119,3 +122,41 @@ export function getAppContextMockState(
|
||||
hasEditPermission: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockLocation(pathname: string): jest.Mock {
|
||||
return jest.fn().mockReturnValue({
|
||||
pathname,
|
||||
});
|
||||
}
|
||||
|
||||
export function mockQueryParams(
|
||||
params: Record<string, string | null>,
|
||||
): URLSearchParams {
|
||||
const realUrlQuery = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== null) {
|
||||
realUrlQuery.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.create(URLSearchParams.prototype, {
|
||||
toString: { value: (): string => realUrlQuery.toString() },
|
||||
get: { value: (key: string): string | null => realUrlQuery.get(key) },
|
||||
});
|
||||
}
|
||||
|
||||
export function convertRoutingPolicyToApiResponse(
|
||||
routingPolicy: RoutingPolicy,
|
||||
): ApiRoutingPolicy {
|
||||
return {
|
||||
id: routingPolicy.id,
|
||||
name: routingPolicy.name,
|
||||
expression: routingPolicy.expression,
|
||||
channels: routingPolicy.channels,
|
||||
description: routingPolicy.description || '',
|
||||
createdAt: routingPolicy.createdAt || '',
|
||||
updatedAt: routingPolicy.updatedAt || '',
|
||||
createdBy: routingPolicy.createdBy || '',
|
||||
updatedBy: routingPolicy.updatedBy || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
act,
|
||||
renderHook,
|
||||
RenderHookResult,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
|
||||
import { UseRoutingPoliciesReturn } from '../types';
|
||||
import useRoutingPolicies from '../useRoutingPolicies';
|
||||
import {
|
||||
convertRoutingPolicyToApiResponse,
|
||||
MOCK_CHANNEL_1,
|
||||
MOCK_CHANNEL_2,
|
||||
MOCK_ROUTING_POLICY_1,
|
||||
MOCK_ROUTING_POLICY_2,
|
||||
} from './testUtils';
|
||||
|
||||
const mockHistoryReplace = jest.fn();
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: (): any => ({
|
||||
...jest.requireActual('react-router-dom').useHistory(),
|
||||
replace: mockHistoryReplace,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockDebouncedFn = jest.fn((fn: () => void) => fn);
|
||||
jest.mock('hooks/useDebouncedFunction', () => ({
|
||||
__esModule: true,
|
||||
default: (fn: () => void): (() => void) => mockDebouncedFn(fn),
|
||||
}));
|
||||
|
||||
const mockRefetchRoutingPolicies = jest.fn();
|
||||
const mockCreateRoutingPolicy = jest.fn();
|
||||
const mockUpdateRoutingPolicy = jest.fn();
|
||||
const mockDeleteRoutingPolicy = jest.fn();
|
||||
jest.mock('hooks/routingPolicies/useGetRoutingPolicies', () => ({
|
||||
useGetRoutingPolicies: (): UseQueryResult<
|
||||
SuccessResponseV2<GetRoutingPoliciesResponse>,
|
||||
Error
|
||||
> =>
|
||||
({
|
||||
data: {
|
||||
data: {
|
||||
data: [
|
||||
convertRoutingPolicyToApiResponse(MOCK_ROUTING_POLICY_1),
|
||||
convertRoutingPolicyToApiResponse(MOCK_ROUTING_POLICY_2),
|
||||
],
|
||||
},
|
||||
},
|
||||
refetch: mockRefetchRoutingPolicies,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any),
|
||||
}));
|
||||
jest.mock('hooks/routingPolicies/useCreateRoutingPolicy', () => ({
|
||||
useCreateRoutingPolicy: (): any => ({
|
||||
mutate: mockCreateRoutingPolicy,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/routingPolicies/useUpdateRoutingPolicy', () => ({
|
||||
useUpdateRoutingPolicy: (): any => ({
|
||||
mutate: mockUpdateRoutingPolicy,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/routingPolicies/useDeleteRoutingPolicy', () => ({
|
||||
useDeleteRoutingPolicy: (): any => ({
|
||||
mutate: mockDeleteRoutingPolicy,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('api/channels/getAll', () => ({
|
||||
__esModule: true,
|
||||
default: (): any =>
|
||||
Promise.resolve({
|
||||
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
|
||||
}),
|
||||
}));
|
||||
|
||||
const ROUTING_POLICY_1_NAME = 'Routing Policy 1';
|
||||
const TEST_SEARCH_TERM = 'test search';
|
||||
|
||||
describe('useRoutingPolicies', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderHookWithWrapper = (
|
||||
initialEntries: string[] = ['/alerts'],
|
||||
): RenderHookResult<UseRoutingPoliciesReturn, unknown> => {
|
||||
const history = createMemoryHistory({ initialEntries });
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router history={history}>{children}</Router>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
return renderHook(() => useRoutingPolicies(), { wrapper });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all policies when search term is empty', () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
expect(result.current.searchTerm).toBe('');
|
||||
expect(result.current.routingPoliciesData).toHaveLength(2);
|
||||
expect(result.current.routingPoliciesData).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: MOCK_ROUTING_POLICY_1.name }),
|
||||
expect.objectContaining({ name: MOCK_ROUTING_POLICY_2.name }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter policies exactly matching the search term', () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchTerm(MOCK_ROUTING_POLICY_1.name);
|
||||
});
|
||||
|
||||
expect(result.current.searchTerm).toBe(MOCK_ROUTING_POLICY_1.name);
|
||||
expect(result.current.routingPoliciesData).toHaveLength(1);
|
||||
expect(result.current.routingPoliciesData[0].name).toBe(
|
||||
MOCK_ROUTING_POLICY_1.name,
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter policies partially matching the search term', () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchTerm('Policy 1');
|
||||
});
|
||||
|
||||
expect(result.current.searchTerm).toBe('Policy 1');
|
||||
expect(result.current.routingPoliciesData).toHaveLength(1);
|
||||
expect(result.current.routingPoliciesData[0].name).toBe(
|
||||
MOCK_ROUTING_POLICY_1.name,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no policies match the search term', () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchTerm('random search term');
|
||||
});
|
||||
|
||||
expect(result.current.searchTerm).toBe('random search term');
|
||||
expect(result.current.routingPoliciesData).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should initialize search term from URL query parameter', () => {
|
||||
const { result } = renderHookWithWrapper([
|
||||
`/alerts?search=${encodeURIComponent(ROUTING_POLICY_1_NAME)}`,
|
||||
]);
|
||||
|
||||
expect(result.current.searchTerm).toBe(ROUTING_POLICY_1_NAME);
|
||||
expect(result.current.routingPoliciesData).toHaveLength(1);
|
||||
expect(result.current.routingPoliciesData[0].name).toBe(
|
||||
ROUTING_POLICY_1_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize with empty search when no search param in URL', () => {
|
||||
const { result } = renderHookWithWrapper(['/alerts']);
|
||||
|
||||
expect(result.current.searchTerm).toBe('');
|
||||
expect(result.current.routingPoliciesData).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should update URL when search term is set', async () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchTerm(TEST_SEARCH_TERM);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryReplace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArg = mockHistoryReplace.mock.calls[0][0];
|
||||
expect(callArg).toContain('search=test+search');
|
||||
});
|
||||
|
||||
it('should remove search param from URL when search is cleared', async () => {
|
||||
const { result } = renderHookWithWrapper(['/alerts?search=existing']);
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchTerm('');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryReplace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const callArg = mockHistoryReplace.mock.calls[0][0];
|
||||
expect(callArg).toBe('/alerts?');
|
||||
});
|
||||
|
||||
it('should filter policies by description', () => {
|
||||
const { result } = renderHookWithWrapper();
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchTerm(MOCK_ROUTING_POLICY_1.description || '');
|
||||
});
|
||||
|
||||
expect(result.current.searchTerm).toBe(MOCK_ROUTING_POLICY_1.description);
|
||||
expect(result.current.routingPoliciesData).toHaveLength(1);
|
||||
expect(result.current.routingPoliciesData[0].description).toBe(
|
||||
MOCK_ROUTING_POLICY_1.description,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,19 @@
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state-svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -97,11 +110,24 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.policy-list-item-header-title {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
flex: 0 1 500px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +140,7 @@
|
||||
.ant-typography:first-child {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -123,7 +149,7 @@
|
||||
div .ant-typography {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -228,6 +254,8 @@
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export type PolicyDetailsModalMode = 'create' | 'edit' | null;
|
||||
|
||||
export interface RoutingPolicyListProps {
|
||||
routingPolicies: RoutingPolicy[];
|
||||
refetchRoutingPolicies: () => void;
|
||||
isRoutingPoliciesLoading: boolean;
|
||||
isRoutingPoliciesFetching: boolean;
|
||||
isRoutingPoliciesError: boolean;
|
||||
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
|
||||
handleDeleteModalOpen: HandleDeleteModalOpen;
|
||||
@@ -80,7 +82,9 @@ export interface UseRoutingPoliciesReturn {
|
||||
selectedRoutingPolicy: RoutingPolicy | null;
|
||||
routingPoliciesData: RoutingPolicy[];
|
||||
isLoadingRoutingPolicies: boolean;
|
||||
isFetchingRoutingPolicies: boolean;
|
||||
isErrorRoutingPolicies: boolean;
|
||||
refetchRoutingPolicies: () => void;
|
||||
// Channels
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
|
||||
@@ -8,8 +8,11 @@ import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPo
|
||||
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
|
||||
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
|
||||
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -28,9 +31,11 @@ import {
|
||||
|
||||
function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
|
||||
// Local state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState(urlQuery.get('search') || '');
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [
|
||||
policyDetailsModalState,
|
||||
@@ -44,9 +49,27 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
setSelectedRoutingPolicy,
|
||||
] = useState<RoutingPolicy | null>(null);
|
||||
|
||||
const updateUrlWithSearch = useDebouncedFn((value) => {
|
||||
const searchValue = value as string;
|
||||
if (searchValue) {
|
||||
urlQuery.set('search', searchValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
}
|
||||
const url = `/alerts?${urlQuery.toString()}`;
|
||||
history.replace(url);
|
||||
}, 300);
|
||||
|
||||
const handleSearch = (value: string): void => {
|
||||
setSearchTerm(value);
|
||||
updateUrlWithSearch(value);
|
||||
};
|
||||
|
||||
// Routing Policies list
|
||||
const {
|
||||
data: routingPolicies,
|
||||
refetch: refetchRoutingPolicies,
|
||||
isFetching: isFetchingRoutingPolicies,
|
||||
isLoading: isLoadingRoutingPolicies,
|
||||
isError: isErrorRoutingPolicies,
|
||||
} = useGetRoutingPolicies();
|
||||
@@ -55,8 +78,10 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
const unfilteredRoutingPolicies = mapApiResponseToRoutingPolicies(
|
||||
routingPolicies as SuccessResponseV2<GetRoutingPoliciesResponse>,
|
||||
);
|
||||
return unfilteredRoutingPolicies.filter((routingPolicy) =>
|
||||
routingPolicy.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
return unfilteredRoutingPolicies.filter(
|
||||
(routingPolicy) =>
|
||||
routingPolicy.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
routingPolicy.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}, [routingPolicies, searchTerm]);
|
||||
|
||||
@@ -213,7 +238,9 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
selectedRoutingPolicy,
|
||||
routingPoliciesData,
|
||||
isLoadingRoutingPolicies,
|
||||
isFetchingRoutingPolicies,
|
||||
isErrorRoutingPolicies,
|
||||
refetchRoutingPolicies,
|
||||
// Channels
|
||||
channels,
|
||||
isLoadingChannels,
|
||||
@@ -221,7 +248,7 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
refreshChannels,
|
||||
// Search
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
setSearchTerm: handleSearch,
|
||||
// Delete Modal
|
||||
isDeleteModalOpen,
|
||||
handleDeleteModalOpen,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Popover, Spin, Tooltip } from 'antd';
|
||||
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
|
||||
import cx from 'classnames';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { useTraceActions } from 'hooks/trace/useTraceActions';
|
||||
import {
|
||||
@@ -124,7 +125,7 @@ export default function AttributeActions({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="action-btn">
|
||||
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||
<Button
|
||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
padding-block: 12px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
@@ -25,8 +25,10 @@
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
padding: 2px 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-500);
|
||||
.action-btn {
|
||||
display: flex;
|
||||
}
|
||||
@@ -81,12 +83,15 @@
|
||||
|
||||
.action-btn {
|
||||
display: none;
|
||||
|
||||
&--is-open {
|
||||
display: flex;
|
||||
}
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
@@ -149,6 +154,9 @@
|
||||
.attributes-corner {
|
||||
.attributes-container {
|
||||
.item {
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
.item-key {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
@@ -163,8 +171,6 @@
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
|
||||
.filter-btn {
|
||||
background: var(--bg-vanilla-200);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './Attributes.styles.scss';
|
||||
|
||||
import { Input, Tooltip, Typography } from 'antd';
|
||||
import { Input, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
|
||||
import { flattenObject } from 'container/LogDetailedView/utils';
|
||||
@@ -83,37 +83,41 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
||||
<section
|
||||
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
|
||||
>
|
||||
{datasource.map((item) => (
|
||||
<div
|
||||
className={cx('item', { pinned: pinnedAttributes[item.field] })}
|
||||
key={`${item.field} + ${item.value}`}
|
||||
>
|
||||
<div className="item-key-wrapper">
|
||||
<Typography.Text className="item-key" ellipsis>
|
||||
{item.field}
|
||||
</Typography.Text>
|
||||
{pinnedAttributes[item.field] && (
|
||||
<Pin size={14} className="pin-icon" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<Tooltip title={item.value}>
|
||||
{datasource
|
||||
.filter((item) => !!item.value && item.value !== '-')
|
||||
.map((item) => (
|
||||
<div
|
||||
className={cx('item', { pinned: pinnedAttributes[item.field] })}
|
||||
key={`${item.field} + ${item.value}`}
|
||||
>
|
||||
<div className="item-key-wrapper">
|
||||
<Typography.Text className="item-key" ellipsis>
|
||||
{item.field}
|
||||
</Typography.Text>
|
||||
{pinnedAttributes[item.field] && (
|
||||
<Pin size={14} className="pin-icon" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<div className="value-wrapper">
|
||||
<div className="copy-wrapper">
|
||||
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
|
||||
<CopyClipboardHOC
|
||||
entityKey={item.value}
|
||||
textToCopy={item.value}
|
||||
tooltipText={item.value}
|
||||
>
|
||||
<Typography.Text className="item-value" ellipsis>
|
||||
{item.value}
|
||||
</Typography.Text>
|
||||
</CopyClipboardHOC>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
isPinned={pinnedAttributes[item.field]}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
<AttributeActions
|
||||
record={item}
|
||||
isPinned={pinnedAttributes[item.field]}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -471,6 +471,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-signals-section {
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.ant-btn.ant-btn-default {
|
||||
padding: 0 15px;
|
||||
&:not(:hover) {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +621,12 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.ant-tabs-content-holder {
|
||||
.bg-border {
|
||||
background: var(--bg-vanilla-300);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
.span-details-drawer {
|
||||
border-left: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
@@ -813,6 +835,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.related-signals-section {
|
||||
.related-signals-button-group {
|
||||
.ant-btn.ant-btn-default {
|
||||
&:not(:hover) {
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,11 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { themeColors } from 'constants/theme';
|
||||
@@ -178,11 +176,13 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
themeColors.traceDetailColors,
|
||||
);
|
||||
|
||||
const handleRelatedSignalsChange = useCallback((e: RadioChangeEvent): void => {
|
||||
const selectedView = e.target.value as RelatedSignalsViews;
|
||||
setActiveDrawerView(selectedView);
|
||||
setIsRelatedSignalsOpen(true);
|
||||
}, []);
|
||||
const handleRelatedSignalsClick = useCallback(
|
||||
(view: RelatedSignalsViews): void => {
|
||||
setActiveDrawerView(view);
|
||||
setIsRelatedSignalsOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRelatedSignalsClose = useCallback((): void => {
|
||||
setIsRelatedSignalsOpen(false);
|
||||
@@ -883,12 +883,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
related signals
|
||||
</Typography.Text>
|
||||
<div className="related-signals-section">
|
||||
<SignozRadioGroup
|
||||
value=""
|
||||
options={relatedSignalsOptions}
|
||||
onChange={handleRelatedSignalsChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
<Button.Group className="related-signals-button-group">
|
||||
{relatedSignalsOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
onClick={(): void => handleRelatedSignalsClick(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on metrics tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(infraMetricsButton);
|
||||
@@ -301,17 +301,17 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
|
||||
// Should NOT show infra tab, only logs tab
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: /metrics/i }),
|
||||
screen.queryByRole('button', { name: /metrics/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /logs/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show infra tab when span has infra metadata', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show both logs and infra tabs
|
||||
expect(screen.getByRole('radio', { name: /metrics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /logs/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle pod-only metadata correctly', async () => {
|
||||
@@ -328,7 +328,7 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -364,7 +364,7 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -400,7 +400,7 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -426,8 +426,8 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Initially should show logs tab content
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
@@ -470,10 +470,10 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show infra tab when span has any of: clusterName, podName, nodeName, hostName
|
||||
expect(screen.getByRole('radio', { name: /metrics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -363,7 +363,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Verify logs tab is visible
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(logsButton).toBeVisible();
|
||||
});
|
||||
@@ -372,7 +372,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs view to open and logs to be displayed
|
||||
@@ -393,7 +393,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab to trigger API calls
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete
|
||||
@@ -434,7 +434,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab to trigger API calls
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete
|
||||
@@ -468,7 +468,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
@@ -514,7 +514,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
@@ -560,7 +560,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for logs to load
|
||||
@@ -589,7 +589,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Open logs view
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const logsButton = screen.getByRole('button', { name: /logs/i });
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Wait for all API calls to complete first
|
||||
|
||||
@@ -24,7 +24,6 @@ import { spanServiceNameToColorMapping } from 'lib/getRandomColor';
|
||||
import history from 'lib/history';
|
||||
import { map } from 'lodash-es';
|
||||
import { PanelRight } from 'lucide-react';
|
||||
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ITraceForest, PayloadProps } from 'types/api/trace/getTraceItem';
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
getTreeLevelsCount,
|
||||
IIntervalUnit,
|
||||
INTERVAL_UNITS,
|
||||
SPAN_DETAILS_LEFT_COL_WIDTH,
|
||||
} from './utils';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
@@ -13,6 +13,8 @@ export const filterSpansByString = (
|
||||
return JSON.stringify(spanWithoutChildren).includes(searchString);
|
||||
});
|
||||
|
||||
export const SPAN_DETAILS_LEFT_COL_WIDTH = 350;
|
||||
|
||||
type TTimeUnitName = 'ms' | 's' | 'm' | 'hr' | 'day' | 'week';
|
||||
|
||||
export interface IIntervalUnit {
|
||||
|
||||
@@ -135,6 +135,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert-details-v2 {
|
||||
.alert-details__breadcrumb {
|
||||
padding-left: 32px;
|
||||
.breadcrumb-item {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
margin-right: 16px;
|
||||
|
||||
.alert-info__action-buttons {
|
||||
.alert-action-buttons {
|
||||
.ant-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabs-and-filters {
|
||||
.ant-tabs {
|
||||
.ant-tabs-nav {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
.ant-tabs-tab-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-400);
|
||||
font-size: 13px;
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
.ant-tabs-content {
|
||||
.ant-tabs-tabpane {
|
||||
.alert-history {
|
||||
margin: 0 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.alert-details {
|
||||
&-tabs {
|
||||
|
||||
@@ -2,6 +2,7 @@ import './AlertDetails.styles.scss';
|
||||
|
||||
import { Breadcrumb, Button, Divider, Empty } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import { Filters } from 'components/AlertDetailsFilters/Filters';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import Spinner from 'components/Spinner';
|
||||
@@ -13,7 +14,10 @@ import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import AlertHeader from './AlertHeader/AlertHeader';
|
||||
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
|
||||
@@ -117,6 +121,8 @@ function AlertDetails(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isV2Alert = alertRuleDetails?.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
// Show spinner until we have alert data loaded
|
||||
if (isLoading && !alertRuleDetails) {
|
||||
return <Spinner />;
|
||||
@@ -129,7 +135,9 @@ function AlertDetails(): JSX.Element {
|
||||
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<div className="alert-details">
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
>
|
||||
<Breadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
items={[
|
||||
|
||||
@@ -25,6 +25,11 @@ const menuItemStyle: CSSProperties = {
|
||||
letterSpacing: '0.14px',
|
||||
};
|
||||
|
||||
const menuItemStyleV2: CSSProperties = {
|
||||
fontSize: '13px',
|
||||
letterSpacing: '0.13px',
|
||||
};
|
||||
|
||||
function AlertActionButtons({
|
||||
ruleId,
|
||||
alertDetails,
|
||||
@@ -63,6 +68,8 @@ function AlertActionButtons({
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const finalMenuItemStyle = isV2Alert ? menuItemStyleV2 : menuItemStyle;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
...(!isV2Alert
|
||||
? [
|
||||
@@ -71,7 +78,7 @@ function AlertActionButtons({
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleRename,
|
||||
style: menuItemStyle,
|
||||
style: finalMenuItemStyle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -80,7 +87,7 @@ function AlertActionButtons({
|
||||
label: 'Duplicate',
|
||||
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleAlertDuplicate,
|
||||
style: menuItemStyle,
|
||||
style: finalMenuItemStyle,
|
||||
},
|
||||
{
|
||||
key: 'delete-rule',
|
||||
@@ -88,7 +95,7 @@ function AlertActionButtons({
|
||||
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
|
||||
onClick: handleAlertDelete,
|
||||
style: {
|
||||
...menuItemStyle,
|
||||
...finalMenuItemStyle,
|
||||
color: Color.BG_CHERRY_400,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
align-items: baseline;
|
||||
padding: 0 16px;
|
||||
|
||||
.alert-header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 54px;
|
||||
flex: 1;
|
||||
|
||||
.top-section {
|
||||
display: flex;
|
||||
|
||||
263
frontend/src/pages/AlertList/__tests__/AlertList.test.tsx
Normal file
263
frontend/src/pages/AlertList/__tests__/AlertList.test.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import AlertList from '../index';
|
||||
|
||||
const ALERTS_PATH = '/alerts';
|
||||
const TAB_SELECTOR = '.ant-tabs-tab';
|
||||
const LIST_ALERT_RULES_TEXT = 'List Alert Rules Component';
|
||||
const TRIGGERED_ALERTS_TEXT = 'Triggered Alerts';
|
||||
const ALERT_RULES_TEXT = 'Alert Rules';
|
||||
const CONFIGURATION_TEXT = 'Configuration';
|
||||
const PLANNED_DOWNTIME_TEXT = 'Planned Downtime';
|
||||
const ROUTING_POLICIES_TEXT = 'Routing Policies';
|
||||
const PLANNED_DOWNTIME_SUB_TAB = 'planned-downtime';
|
||||
const ROUTING_POLICIES_SUB_TAB = 'routing-policies';
|
||||
|
||||
const mockUseLocation = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): unknown => mockUseLocation(),
|
||||
}));
|
||||
|
||||
let mockUrlQuery: URLSearchParams;
|
||||
const mockSet = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): unknown => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
jest.mock('components/HeaderRightSection/HeaderRightSection', () => ({
|
||||
__esModule: true,
|
||||
default: function MockHeaderRightSection(): JSX.Element {
|
||||
return <div>Header Right Section</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('pages/AlertDetails', () => ({
|
||||
__esModule: true,
|
||||
default: function MockAlertDetails(): JSX.Element {
|
||||
return <div>Alert Details Component</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/PlannedDowntime/PlannedDowntime', () => ({
|
||||
PlannedDowntime: function MockPlannedDowntime(): JSX.Element {
|
||||
return <div>Planned Downtime Component</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/RoutingPolicies', () => ({
|
||||
__esModule: true,
|
||||
default: function MockRoutingPolicies(): JSX.Element {
|
||||
return <div>Routing Policies Component</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/TriggeredAlerts', () => ({
|
||||
__esModule: true,
|
||||
default: function MockTriggeredAlerts(): JSX.Element {
|
||||
return <div>Triggered Alerts Component</div>;
|
||||
},
|
||||
}));
|
||||
jest.mock('container/ListAlertRules', () => ({
|
||||
__esModule: true,
|
||||
default: function MockListAlertRules(): JSX.Element {
|
||||
return <div>List Alert Rules Component</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockLocation = (pathname: string): void => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname,
|
||||
});
|
||||
};
|
||||
|
||||
const mockQueryParams = (params: Record<string, string | null>): void => {
|
||||
const realUrlQuery = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== null) {
|
||||
realUrlQuery.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
mockSet.mockImplementation((key: string, value: string) => {
|
||||
realUrlQuery.set(key, value);
|
||||
});
|
||||
mockDelete.mockImplementation((key: string) => {
|
||||
realUrlQuery.delete(key);
|
||||
});
|
||||
|
||||
mockUrlQuery = Object.create(URLSearchParams.prototype, {
|
||||
set: { value: mockSet },
|
||||
delete: { value: mockDelete },
|
||||
toString: { value: (): string => realUrlQuery.toString() },
|
||||
get: { value: (key: string): string | null => realUrlQuery.get(key) },
|
||||
});
|
||||
};
|
||||
|
||||
const clickTab = (tabText: string): void => {
|
||||
const tab = screen.getByText(tabText).closest(TAB_SELECTOR);
|
||||
if (tab) {
|
||||
fireEvent.click(tab);
|
||||
}
|
||||
};
|
||||
|
||||
describe('AlertList', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Default Rendering', () => {
|
||||
it('should render AlertRules tab by default when no tab query param is provided', () => {
|
||||
mockQueryParams({});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all three main tabs', () => {
|
||||
mockQueryParams({});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(TRIGGERED_ALERTS_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(CONFIGURATION_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render TriggeredAlerts tab when tab query param is TriggeredAlerts', () => {
|
||||
mockQueryParams({ tab: 'TriggeredAlerts' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText('Triggered Alerts Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render AlertRules tab when tab query param is AlertRules', () => {
|
||||
mockQueryParams({ tab: 'AlertRules' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Configuration tab with default Planned Downtime sub-tab when tab query param is Configuration', () => {
|
||||
mockQueryParams({ tab: 'Configuration' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to TriggeredAlerts tab when clicked', () => {
|
||||
mockQueryParams({ tab: 'AlertRules' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(TRIGGERED_ALERTS_TEXT);
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith('tab', 'TriggeredAlerts');
|
||||
expect(mockDelete).toHaveBeenCalledWith('subTab');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=TriggeredAlerts');
|
||||
});
|
||||
|
||||
it('should navigate to AlertRules tab when clicked', () => {
|
||||
mockQueryParams({ tab: 'TriggeredAlerts' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(ALERT_RULES_TEXT);
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith('tab', 'AlertRules');
|
||||
expect(mockDelete).toHaveBeenCalledWith('subTab');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Tab', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render Configuration tab with default Planned Downtime sub-tab', () => {
|
||||
mockQueryParams({ tab: CONFIGURATION_TEXT });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Routing Policies sub-tab when subTab query param is routing-policies', () => {
|
||||
mockQueryParams({
|
||||
tab: CONFIGURATION_TEXT,
|
||||
subTab: ROUTING_POLICIES_SUB_TAB,
|
||||
});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to Configuration tab with default subTab when clicked', () => {
|
||||
mockQueryParams({ tab: 'AlertRules' });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(CONFIGURATION_TEXT);
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith('tab', CONFIGURATION_TEXT);
|
||||
expect(mockSet).toHaveBeenCalledWith('subTab', PLANNED_DOWNTIME_SUB_TAB);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing subTab when navigating to Configuration tab', () => {
|
||||
mockQueryParams({ tab: 'AlertRules', subTab: ROUTING_POLICIES_SUB_TAB });
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(CONFIGURATION_TEXT);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear subTab when navigating away from Configuration tab', () => {
|
||||
mockQueryParams({
|
||||
tab: CONFIGURATION_TEXT,
|
||||
subTab: PLANNED_DOWNTIME_SUB_TAB,
|
||||
});
|
||||
mockLocation(ALERTS_PATH);
|
||||
|
||||
render(<AlertList />);
|
||||
|
||||
clickTab(ALERT_RULES_TEXT);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('subTab');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,41 +13,53 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
|
||||
import AlertDetails from 'pages/AlertDetails';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { AlertListSubTabs, AlertListTabs } from './types';
|
||||
|
||||
function AllAlertList(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const tab = urlQuery.get('tab');
|
||||
const subTab = urlQuery.get('subTab');
|
||||
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
|
||||
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
|
||||
|
||||
const search = urlQuery.get('search');
|
||||
const handleConfigurationTabChange = useCallback(
|
||||
(subTab: string): void => {
|
||||
urlQuery.set('tab', AlertListTabs.CONFIGURATION);
|
||||
urlQuery.set('subTab', subTab);
|
||||
urlQuery.delete('search');
|
||||
safeNavigate(`/alerts?${urlQuery.toString()}`);
|
||||
},
|
||||
[safeNavigate, urlQuery],
|
||||
);
|
||||
|
||||
const configurationTab = useMemo(() => {
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Planned Downtime',
|
||||
key: 'planned-downtime',
|
||||
key: AlertListSubTabs.PLANNED_DOWNTIME,
|
||||
children: <PlannedDowntime />,
|
||||
},
|
||||
{
|
||||
label: 'Routing Policies',
|
||||
key: 'routing-policies',
|
||||
key: AlertListSubTabs.ROUTING_POLICIES,
|
||||
children: <RoutingPolicies />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Tabs
|
||||
className="configuration-tabs"
|
||||
defaultActiveKey="planned-downtime"
|
||||
activeKey={subTab || AlertListSubTabs.PLANNED_DOWNTIME}
|
||||
items={tabs}
|
||||
onChange={handleConfigurationTabChange}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
}, [subTab, handleConfigurationTabChange]);
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
@@ -57,7 +69,7 @@ function AllAlertList(): JSX.Element {
|
||||
Triggered Alerts
|
||||
</div>
|
||||
),
|
||||
key: 'TriggeredAlerts',
|
||||
key: AlertListTabs.TRIGGERED_ALERTS,
|
||||
children: <TriggeredAlerts />,
|
||||
},
|
||||
{
|
||||
@@ -67,7 +79,7 @@ function AllAlertList(): JSX.Element {
|
||||
Alert Rules
|
||||
</div>
|
||||
),
|
||||
key: 'AlertRules',
|
||||
key: AlertListTabs.ALERT_RULES,
|
||||
children: (
|
||||
<div className="alert-rules-container">
|
||||
{isAlertHistory || isAlertOverview ? <AlertDetails /> : <AllAlertRules />}
|
||||
@@ -81,7 +93,7 @@ function AllAlertList(): JSX.Element {
|
||||
Configuration
|
||||
</div>
|
||||
),
|
||||
key: 'Configuration',
|
||||
key: AlertListTabs.CONFIGURATION,
|
||||
children: configurationTab,
|
||||
},
|
||||
];
|
||||
@@ -90,15 +102,23 @@ function AllAlertList(): JSX.Element {
|
||||
<Tabs
|
||||
destroyInactiveTabPane
|
||||
items={items}
|
||||
activeKey={tab || 'AlertRules'}
|
||||
activeKey={tab || AlertListTabs.ALERT_RULES}
|
||||
onChange={(tab): void => {
|
||||
urlQuery.set('tab', tab);
|
||||
let params = `tab=${tab}`;
|
||||
|
||||
if (search) {
|
||||
params += `&search=${search}`;
|
||||
// If navigating to Configuration tab, set default subTab
|
||||
if (tab === AlertListTabs.CONFIGURATION) {
|
||||
const currentSubTab = subTab || AlertListSubTabs.PLANNED_DOWNTIME;
|
||||
urlQuery.set('subTab', currentSubTab);
|
||||
} else {
|
||||
// Clear subTab when navigating out of Configuration tab
|
||||
urlQuery.delete('subTab');
|
||||
}
|
||||
safeNavigate(`/alerts?${params}`);
|
||||
|
||||
// Clear search when navigating to any tab
|
||||
urlQuery.delete('search');
|
||||
|
||||
safeNavigate(`/alerts?${urlQuery.toString()}`);
|
||||
}}
|
||||
className={`alerts-container ${
|
||||
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
|
||||
|
||||
10
frontend/src/pages/AlertList/types.ts
Normal file
10
frontend/src/pages/AlertList/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export enum AlertListSubTabs {
|
||||
PLANNED_DOWNTIME = 'planned-downtime',
|
||||
ROUTING_POLICIES = 'routing-policies',
|
||||
}
|
||||
|
||||
export enum AlertListTabs {
|
||||
TRIGGERED_ALERTS = 'TriggeredAlerts',
|
||||
ALERT_RULES = 'AlertRules',
|
||||
CONFIGURATION = 'Configuration',
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
.old-trace-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.top-header {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
|
||||
.new-cta-btn {
|
||||
display: flex;
|
||||
padding: 4px 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
|
||||
/* Bifrost (Ancient)/Content/sm */
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
box-shadow: none;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.old-trace-container {
|
||||
.top-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.new-cta-btn {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import TraceDetail from '..';
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/badge', () => ({
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/resizable', () => ({
|
||||
ResizableHandle: jest.fn(),
|
||||
ResizablePanel: jest.fn(),
|
||||
ResizablePanelGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACE_DETAIL}`,
|
||||
search: '?spanId=28a8a67365d0bd8b&levelUp=0&levelDown=0',
|
||||
}),
|
||||
|
||||
useParams: jest.fn().mockReturnValue({
|
||||
id: '000000000000000071dc9b0a338729b4',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/TraceFlameGraph/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div>TraceFlameGraph</div>,
|
||||
}));
|
||||
|
||||
describe('TraceDetail', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2023-10-20'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should render tracedetail', async () => {
|
||||
const { findByText, getByText, getAllByText, getByPlaceholderText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
// as we have an active spanId, it should scroll to the selected span
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
|
||||
// assertions
|
||||
expect(getByText('TraceFlameGraph')).toBeInTheDocument();
|
||||
expect(getByText('Focus on selected span')).toBeInTheDocument();
|
||||
|
||||
// span action buttons
|
||||
expect(getByText('Reset Focus')).toBeInTheDocument();
|
||||
expect(getByText('50 Spans')).toBeInTheDocument();
|
||||
|
||||
// trace span detail - parent -> child
|
||||
expect(getAllByText('frontend')[0]).toBeInTheDocument();
|
||||
expect(getByText('776.76 ms')).toBeInTheDocument();
|
||||
[
|
||||
{ trace: 'HTTP GET /dispatch', duration: '776.76 ms', count: '50' },
|
||||
{ trace: 'HTTP GET: /customer', duration: '349.44 ms', count: '4' },
|
||||
{
|
||||
trace: '/driver.DriverService/FindNearest',
|
||||
duration: '173.10 ms',
|
||||
count: '15',
|
||||
},
|
||||
// and so on ...
|
||||
].forEach((traceDetail) => {
|
||||
expect(getByText(traceDetail.trace)).toBeInTheDocument();
|
||||
expect(getByText(traceDetail.duration)).toBeInTheDocument();
|
||||
expect(getByText(traceDetail.count)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Details for selected Span
|
||||
expect(getByText('Details for selected Span')).toBeInTheDocument();
|
||||
['Service', 'Operation', 'SpanKind', 'StatusCodeString'].forEach((detail) => {
|
||||
expect(getByText(detail)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// go to related logs button
|
||||
const goToRelatedLogsButton = getByText('Go to Related logs');
|
||||
expect(goToRelatedLogsButton).toBeInTheDocument();
|
||||
|
||||
// Tag and Event tabs
|
||||
expect(getByText('Tags')).toBeInTheDocument();
|
||||
expect(getByText('Events')).toBeInTheDocument();
|
||||
expect(getByPlaceholderText('traceDetails:search_tags')).toBeInTheDocument();
|
||||
|
||||
// Tag details
|
||||
[
|
||||
{ title: 'client-uuid', value: '64a18ffd5f8adbfb' },
|
||||
{ title: 'component', value: 'net/http' },
|
||||
{ title: 'host.name', value: '4f6ec470feea' },
|
||||
{ title: 'http.method', value: 'GET' },
|
||||
{ title: 'http.url', value: '/route?dropoff=728%2C326&pickup=165%2C543' },
|
||||
{ title: 'http.status_code', value: '200' },
|
||||
{ title: 'ip', value: '172.25.0.2' },
|
||||
{ title: 'opencensus.exporterversion', value: 'Jaeger-Go-2.30.0' },
|
||||
].forEach((tag) => {
|
||||
expect(getByText(tag.title)).toBeInTheDocument();
|
||||
expect(getByText(tag.value)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// see full value
|
||||
expect(getAllByText('View full value')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tracedetail events tab', async () => {
|
||||
const { findByText, getByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByText('Events'));
|
||||
|
||||
expect(await screen.findByText('HTTP request received')).toBeInTheDocument();
|
||||
|
||||
// event details
|
||||
[
|
||||
{ title: 'Event Start Time', value: '527.60 ms' },
|
||||
{ title: 'level', value: 'info' },
|
||||
].forEach((tag) => {
|
||||
expect(getByText(tag.title)).toBeInTheDocument();
|
||||
expect(getByText(tag.value)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(getByText('View full log event message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle slider - selected span details', async () => {
|
||||
const { findByTestId, queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const slider = await findByTestId('span-details-sider');
|
||||
expect(slider).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(slider.querySelector('.expand-collapse-btn') as HTMLElement);
|
||||
|
||||
expect(queryByText('Details for selected Span')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to selected another span and see its detail', async () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
const spanTitle = getByText('/driver.DriverService/FindNearest');
|
||||
expect(spanTitle).toBeInTheDocument();
|
||||
fireEvent.click(spanTitle);
|
||||
|
||||
// Tag details
|
||||
[
|
||||
{ title: 'client-uuid', value: '6fb81b8ca91b2b4d' },
|
||||
{ title: 'component', value: 'gRPC' },
|
||||
{ title: 'host.name', value: '4f6ec470feea' },
|
||||
].forEach((tag) => {
|
||||
expect(getByText(tag.title)).toBeInTheDocument();
|
||||
expect(getByText(tag.value)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('focus on selected span and reset focus action', async () => {
|
||||
const { getByText, getAllByText } = render(
|
||||
<MemoryRouter initialEntries={['/trace/000000000000000071dc9b0a338729b4']}>
|
||||
<Route path={ROUTES.TRACE_DETAIL}>
|
||||
<TraceDetail />
|
||||
</Route>
|
||||
,
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Trace Details')).toBeInTheDocument();
|
||||
|
||||
const spanTitle = getByText('/driver.DriverService/FindNearest');
|
||||
expect(spanTitle).toBeInTheDocument();
|
||||
fireEvent.click(spanTitle);
|
||||
|
||||
expect(await screen.findByText('6fb81b8ca91b2b4d')).toBeInTheDocument();
|
||||
|
||||
// focus on selected span
|
||||
const focusButton = getByText('Focus on selected span');
|
||||
expect(focusButton).toBeInTheDocument();
|
||||
fireEvent.click(focusButton);
|
||||
|
||||
// assert selected span
|
||||
expect(getByText('15 Spans')).toBeInTheDocument();
|
||||
expect(getAllByText('/driver.DriverService/FindNearest')).toHaveLength(3);
|
||||
expect(getByText('173.10 ms')).toBeInTheDocument();
|
||||
|
||||
// reset focus
|
||||
expect(screen.queryByText('HTTP GET /dispatch')).not.toBeInTheDocument();
|
||||
|
||||
const resetFocusButton = getByText('Reset Focus');
|
||||
expect(resetFocusButton).toBeInTheDocument();
|
||||
fireEvent.click(resetFocusButton);
|
||||
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
expect(screen.queryByText('HTTP GET /dispatch')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export const SPAN_DETAILS_LEFT_COL_WIDTH = 350;
|
||||
|
||||
export const noEventMessage =
|
||||
'The requested trace id was not found. Sometimes this happens because of insertion delay in trace data. Please try again after some time';
|
||||
@@ -1,76 +0,0 @@
|
||||
import './TraceDetail.styles.scss';
|
||||
|
||||
import { Button, Typography } from 'antd';
|
||||
import getTraceItem from 'api/trace/getTraceItem';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TraceDetailContainer from 'container/TraceDetail';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { Undo } from 'lucide-react';
|
||||
import TraceDetailsPage from 'pages/TraceDetailV2';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Props as TraceDetailProps } from 'types/api/trace/getTraceItem';
|
||||
|
||||
import { noEventMessage } from './constants';
|
||||
|
||||
function TraceDetail(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailProps>();
|
||||
const [showNewTraceDetails, setShowNewTraceDetails] = useState<boolean>(false);
|
||||
const urlQuery = useUrlQuery();
|
||||
const { spanId, levelUp, levelDown } = useMemo(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId'),
|
||||
levelUp: urlQuery.get('levelUp'),
|
||||
levelDown: urlQuery.get('levelDown'),
|
||||
}),
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
const { data: traceDetailResponse, error, isLoading, isError } = useQuery(
|
||||
`getTraceItem/${id}`,
|
||||
() => getTraceItem({ id, spanId, levelUp, levelDown }),
|
||||
{
|
||||
cacheTime: 3000,
|
||||
},
|
||||
);
|
||||
|
||||
if (showNewTraceDetails) {
|
||||
return <TraceDetailsPage />;
|
||||
}
|
||||
|
||||
if (traceDetailResponse?.error || error || isError) {
|
||||
return (
|
||||
<Typography>
|
||||
{traceDetailResponse?.error || 'Something went wrong'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !(traceDetailResponse && traceDetailResponse.payload)) {
|
||||
return <Spinner tip="Loading.." />;
|
||||
}
|
||||
|
||||
if (traceDetailResponse.payload[0].events.length === 0) {
|
||||
return <NotFound text={noEventMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="old-trace-container">
|
||||
<div className="top-header">
|
||||
<Button
|
||||
onClick={(): void => setShowNewTraceDetails(true)}
|
||||
icon={<Undo size={14} />}
|
||||
type="text"
|
||||
className="new-cta-btn"
|
||||
>
|
||||
New Trace Detail
|
||||
</Button>
|
||||
</div>
|
||||
<TraceDetailContainer response={traceDetailResponse.payload} />;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetail;
|
||||
@@ -1,12 +1,10 @@
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
import { Button, Tabs } from 'antd';
|
||||
import { Tabs } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
|
||||
import TraceDetail from 'pages/TraceDetail';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||
|
||||
import TraceDetailsV2 from './TraceDetailV2';
|
||||
|
||||
@@ -16,11 +14,10 @@ interface INewTraceDetailProps {
|
||||
key: string;
|
||||
children: JSX.Element;
|
||||
}[];
|
||||
handleOldTraceDetails: () => void;
|
||||
}
|
||||
|
||||
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
const { items, handleOldTraceDetails } = props;
|
||||
const { items } = props;
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
<Tabs
|
||||
@@ -39,24 +36,12 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
history.push(ROUTES.TRACES_FUNNELS);
|
||||
}
|
||||
}}
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={handleOldTraceDetails}
|
||||
className="old-switch"
|
||||
icon={<Undo size={14} />}
|
||||
>
|
||||
Old Trace Details
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TraceDetailsPage(): JSX.Element {
|
||||
const [showOldTraceDetails, setShowOldTraceDetails] = useState<boolean>(false);
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
@@ -86,13 +71,6 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
children: <div />,
|
||||
},
|
||||
];
|
||||
const handleOldTraceDetails = useCallback(() => {
|
||||
setShowOldTraceDetails(true);
|
||||
}, []);
|
||||
|
||||
return showOldTraceDetails ? (
|
||||
<TraceDetail />
|
||||
) : (
|
||||
<NewTraceDetail items={items} handleOldTraceDetails={handleOldTraceDetails} />
|
||||
);
|
||||
return <NewTraceDetail items={items} />;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,8 @@
|
||||
min-width: 0;
|
||||
|
||||
.ant-select {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
border: none;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -167,6 +169,10 @@
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.11
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.16
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
|
||||
|
||||
4
go.sum
4
go.sum
@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.11 h1:fZMKAjRmgzW44+hEhF6ywi4VjFZQjJ8QrFBbgBsjmF4=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.11/go.mod h1:W0Z82wJWkJxz2RVun/RMwxue3g7ut47Xxl+SFqdJGus=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.16 h1:gpl+wXclYUKT0p4+gBq22XeRYWwEoZ9f35vogqMvkLQ=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.16/go.mod h1:W0Z82wJWkJxz2RVun/RMwxue3g7ut47Xxl+SFqdJGus=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
|
||||
@@ -27,7 +27,7 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
|
||||
}
|
||||
|
||||
if !factorPassword.Equals(password) {
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email orpassword")
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
|
||||
}
|
||||
|
||||
return authtypes.NewIdentity(user.ID, orgID, user.Email, user.Role), nil
|
||||
|
||||
@@ -112,7 +112,7 @@ func (b *base) WithUrl(u string) *base {
|
||||
}
|
||||
}
|
||||
|
||||
// WithUrl adds additional messages to the base error and returns a new base error.
|
||||
// WithAdditional adds additional messages to the base error and returns a new base error.
|
||||
func (b *base) WithAdditional(a ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
|
||||
@@ -51,3 +51,88 @@ func TestUnwrapb(t *testing.T) {
|
||||
atyp, _, _, _, _, _ = Unwrapb(oerr)
|
||||
assert.Equal(t, TypeInternal, atyp)
|
||||
}
|
||||
|
||||
func TestWithAdditionalf(t *testing.T) {
|
||||
t.Run("adds additional message to base error", func(t *testing.T) {
|
||||
typ := typ{"test-error"}
|
||||
baseErr := New(typ, MustNewCode("test_code"), "primary message")
|
||||
|
||||
result := WithAdditionalf(baseErr, "additional context %d", 456)
|
||||
|
||||
assert.NotNil(t, result)
|
||||
_, _, msg, _, _, additional := Unwrapb(result)
|
||||
assert.Equal(t, "primary message", msg, "primary message should not change")
|
||||
assert.Equal(t, []string{"additional context 456"}, additional)
|
||||
})
|
||||
|
||||
t.Run("adds additional message to non-base error", func(t *testing.T) {
|
||||
stdErr := errors.New("some error")
|
||||
|
||||
result := WithAdditionalf(stdErr, "extra info: %s", "details")
|
||||
|
||||
assert.NotNil(t, result)
|
||||
_, _, _, _, _, additional := Unwrapb(result)
|
||||
assert.Equal(t, []string{"extra info: details"}, additional)
|
||||
})
|
||||
|
||||
t.Run("appends to existing additional messages", func(t *testing.T) {
|
||||
typ := typ{"test-error"}
|
||||
baseErr := New(typ, MustNewCode("test_code"), "message").
|
||||
WithAdditional("first additional", "second additional")
|
||||
|
||||
result := WithAdditionalf(baseErr, "third additional %s", "msg")
|
||||
|
||||
_, _, _, _, _, additional := Unwrapb(result)
|
||||
assert.Equal(t, []string{
|
||||
"first additional",
|
||||
"second additional",
|
||||
"third additional msg",
|
||||
}, additional)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithUrl(t *testing.T) {
|
||||
t.Run("adds url to base error", func(t *testing.T) {
|
||||
typ := typ{"test-error"}
|
||||
baseErr := New(typ, MustNewCode("test_code"), "error message")
|
||||
|
||||
result := baseErr.WithUrl("https://docs.signoz.io/errors")
|
||||
|
||||
_, _, _, _, url, _ := Unwrapb(result)
|
||||
assert.Equal(t, "https://docs.signoz.io/errors", url)
|
||||
})
|
||||
|
||||
t.Run("replaces existing url", func(t *testing.T) {
|
||||
typ := typ{"test-error"}
|
||||
baseErr := New(typ, MustNewCode("test_code"), "error message").
|
||||
WithUrl("https://old-url.com")
|
||||
|
||||
result := baseErr.WithUrl("https://new-url.com")
|
||||
|
||||
_, _, _, _, url, _ := Unwrapb(result)
|
||||
assert.Equal(t, "https://new-url.com", url)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithAdditional(t *testing.T) {
|
||||
t.Run("adds additional messages to base error", func(t *testing.T) {
|
||||
typ := typ{"test-error"}
|
||||
baseErr := New(typ, MustNewCode("test_code"), "main message")
|
||||
|
||||
result := baseErr.WithAdditional("hint 1", "hint 2", "hint 3")
|
||||
|
||||
_, _, _, _, _, additional := Unwrapb(result)
|
||||
assert.Equal(t, []string{"hint 1", "hint 2", "hint 3"}, additional)
|
||||
})
|
||||
|
||||
t.Run("replaces existing additional messages", func(t *testing.T) {
|
||||
typ := typ{"test-error"}
|
||||
baseErr := New(typ, MustNewCode("test_code"), "message").
|
||||
WithAdditional("old hint")
|
||||
|
||||
result := baseErr.WithAdditional("new hint 1", "new hint 2")
|
||||
|
||||
_, _, _, _, _, additional := Unwrapb(result)
|
||||
assert.Equal(t, []string{"new hint 1", "new hint 2"}, additional)
|
||||
})
|
||||
}
|
||||
|
||||
687
pkg/parser/queryfilterextractor/clickhouse.go
Normal file
687
pkg/parser/queryfilterextractor/clickhouse.go
Normal file
@@ -0,0 +1,687 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
clickhouse "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// MetricNameColumn is the column name used for filtering metrics
|
||||
MetricNameColumn = "metric_name"
|
||||
)
|
||||
|
||||
// ClickHouseFilterExtractor extracts metric names and grouping keys from ClickHouse SQL queries
|
||||
type ClickHouseFilterExtractor struct{}
|
||||
|
||||
// NewClickHouseFilterExtractor creates a new ClickHouse filter extractor
|
||||
func NewClickHouseFilterExtractor() *ClickHouseFilterExtractor {
|
||||
return &ClickHouseFilterExtractor{}
|
||||
}
|
||||
|
||||
// Extract parses a ClickHouse query and extracts metric names and grouping keys
|
||||
func (e *ClickHouseFilterExtractor) Extract(query string) (*FilterResult, error) {
|
||||
p := clickhouse.NewParser(query)
|
||||
stmts, err := p.ParseStmts()
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse clickhouse query: %s", err.Error())
|
||||
}
|
||||
|
||||
result := &FilterResult{MetricNames: []string{}, GroupByColumns: []ColumnInfo{}}
|
||||
|
||||
metricNames := make(map[string]bool)
|
||||
|
||||
// Track top-level queries for GROUP BY extraction
|
||||
topLevelQueries := make(map[*clickhouse.SelectQuery]bool)
|
||||
|
||||
// Process all statements
|
||||
for _, stmt := range stmts {
|
||||
selectQuery, ok := stmt.(*clickhouse.SelectQuery)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as top-level
|
||||
topLevelQueries[selectQuery] = true
|
||||
|
||||
// Walk the AST to extract metrics
|
||||
clickhouse.Walk(selectQuery, func(node clickhouse.Expr) bool {
|
||||
e.fillMetricNamesFromExpr(node, metricNames)
|
||||
return true // Continue traversal
|
||||
})
|
||||
}
|
||||
|
||||
// Extract GROUP BY from the top-level queries by first building a map of CTEs and
|
||||
// then recursively extracting the GROUP BY from the CTEs and subqueries.
|
||||
|
||||
// Build CTE map for all top-level queries
|
||||
cteMap := make(map[string]*clickhouse.SelectQuery)
|
||||
for query := range topLevelQueries {
|
||||
e.buildCTEMap(query, cteMap)
|
||||
}
|
||||
|
||||
// Extract GROUP BY with aliases and origins from the CTEs and subqueries using recursive approach
|
||||
// Use a map to handle duplicates (last ColumnInfo wins across queries)
|
||||
groupByColumnsMap := make(map[string]ColumnInfo) // column name -> ColumnInfo
|
||||
visited := make(map[*clickhouse.SelectQuery]bool)
|
||||
for query := range topLevelQueries {
|
||||
columns, err := e.extractGroupByColumns(query, cteMap, visited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, col := range columns {
|
||||
// Last column info wins for duplicate columns across multiple queries
|
||||
groupByColumnsMap[col.Name] = col
|
||||
}
|
||||
}
|
||||
|
||||
// Convert sets to slices
|
||||
for metric := range metricNames {
|
||||
result.MetricNames = append(result.MetricNames, metric)
|
||||
}
|
||||
|
||||
// Build GroupByColumns from the map
|
||||
for _, colInfo := range groupByColumnsMap {
|
||||
result.GroupByColumns = append(result.GroupByColumns, colInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Metric Name Extraction
|
||||
// ========================================
|
||||
|
||||
// fillMetricNamesFromExpr extracts metric names from various node types
|
||||
func (e *ClickHouseFilterExtractor) fillMetricNamesFromExpr(node clickhouse.Expr, metricNames map[string]bool) {
|
||||
|
||||
switch n := node.(type) {
|
||||
case *clickhouse.BinaryOperation:
|
||||
e.fillMetricFromBinaryOp(n, metricNames)
|
||||
}
|
||||
}
|
||||
|
||||
// fillMetricFromBinaryOp extracts metrics from binary operations
|
||||
func (e *ClickHouseFilterExtractor) fillMetricFromBinaryOp(op *clickhouse.BinaryOperation, metricNames map[string]bool) {
|
||||
// Check if left side is metric_name column
|
||||
leftCol := e.getColumnName(op.LeftExpr)
|
||||
rightCol := e.getColumnName(op.RightExpr)
|
||||
|
||||
// Handle metric_name on left side: metric_name = 'value'
|
||||
if leftCol == MetricNameColumn {
|
||||
e.fillMetricWithBinaryOpConditions(op, op.RightExpr, metricNames)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle metric_name on right side: 'value' = metric_name
|
||||
if rightCol == MetricNameColumn {
|
||||
e.fillMetricWithBinaryOpConditions(op, op.LeftExpr, metricNames)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// fillMetricWithBinaryOpConditions extracts metric names from the value side of a binary operation
|
||||
//
|
||||
// Supported operators:
|
||||
// - "=", "==": Extracts literal string values or values from any() function
|
||||
// - "IN", "GLOBAL IN": Extracts all literal string values from the list
|
||||
//
|
||||
// Unsupported operators (can be added later if needed):
|
||||
// - "!=", "<>", "NOT IN": Negative filters. (e.g., metric_name != 'a')
|
||||
// - "LIKE", "ILIKE": Pattern matching filters
|
||||
// - "NOT LIKE", "NOT ILIKE": Negative pattern matching filters
|
||||
// - "OR", "AND": Boolean operators as the Walk function will automatically traverse both sides
|
||||
// of OR/AND operations and extract metrics from each branch. (e.g., metric_name='a' OR metric_name='b')
|
||||
func (e *ClickHouseFilterExtractor) fillMetricWithBinaryOpConditions(op *clickhouse.BinaryOperation, valueExpr clickhouse.Expr, metricNames map[string]bool) {
|
||||
switch op.Operation {
|
||||
case clickhouse.TokenKindSingleEQ, clickhouse.TokenKindDoubleEQ:
|
||||
// metric_name = 'value' or metric_name = any(['a', 'b'])
|
||||
// Skip if value side is a function call (function-wrapped literals are ignored, test case: CH59)
|
||||
if fn, ok := valueExpr.(*clickhouse.FunctionExpr); ok {
|
||||
// Only handle any() function, skip others like lowercase('cpu')
|
||||
if fn.Name != nil && fn.Name.Name == "any" {
|
||||
e.extractInValues(valueExpr, metricNames)
|
||||
}
|
||||
// Otherwise skip function-wrapped literals
|
||||
} else if val := e.extractStringLiteral(valueExpr); val != "" {
|
||||
metricNames[val] = true
|
||||
}
|
||||
case "IN", "GLOBAL IN":
|
||||
// metric_name IN ('a', 'b', 'c')
|
||||
// GLOBAL IN behaves the same as IN for metric extraction purposes
|
||||
// Skip if value side is a function call (function-wrapped literals are ignored, test case: CH59)
|
||||
if _, ok := valueExpr.(*clickhouse.FunctionExpr); !ok {
|
||||
e.extractInValues(valueExpr, metricNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractStringLiteral extracts a string literal value from an expression
|
||||
func (e *ClickHouseFilterExtractor) extractStringLiteral(expr clickhouse.Expr) string {
|
||||
switch ex := expr.(type) {
|
||||
case *clickhouse.StringLiteral:
|
||||
return ex.Literal
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractInValues extracts values from IN expressions
|
||||
func (e *ClickHouseFilterExtractor) extractInValues(expr clickhouse.Expr, metricNames map[string]bool) {
|
||||
// Find all string literals in the expression
|
||||
strLits := clickhouse.FindAll(expr, func(node clickhouse.Expr) bool {
|
||||
// metric_name passed in `in` condition will be string literal.
|
||||
_, ok := node.(*clickhouse.StringLiteral)
|
||||
return ok
|
||||
})
|
||||
|
||||
for _, strLitNode := range strLits {
|
||||
if strLit, ok := strLitNode.(*clickhouse.StringLiteral); ok {
|
||||
// Unquote the string literal
|
||||
val := e.extractStringLiteral(strLit)
|
||||
if val != "" {
|
||||
metricNames[val] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// GROUP BY Column Extraction
|
||||
// ========================================
|
||||
|
||||
// extractGroupByColumns extracts the GROUP BY columns from a query
|
||||
// It follows the top-down approach where outer GROUP BY overrides inner GROUP BY in subqueries and CTEs.
|
||||
// Returns a slice of ColumnInfo with column names, aliases, and origins
|
||||
func (e *ClickHouseFilterExtractor) extractGroupByColumns(query *clickhouse.SelectQuery, cteMap map[string]*clickhouse.SelectQuery, visited map[*clickhouse.SelectQuery]bool) ([]ColumnInfo, error) {
|
||||
if visited[query] {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Mark this query as visited to prevent cycles
|
||||
visited[query] = true
|
||||
|
||||
// First, check if this query has its own GROUP BY using direct field access
|
||||
hasGroupBy := query.GroupBy != nil
|
||||
|
||||
// If this query has GROUP BY, use it (outer overrides inner)
|
||||
if hasGroupBy {
|
||||
// Extract GROUP BY columns
|
||||
tempGroupBy := make(map[string]bool)
|
||||
e.fillGroupsFromGroupByClause(query.GroupBy, tempGroupBy)
|
||||
|
||||
// Extract SELECT columns and their aliases from the same query level
|
||||
selectAliases := e.extractSelectColumns(query)
|
||||
|
||||
// Build ColumnInfo array by matching GROUP BY with SELECT aliases and origins
|
||||
result := []ColumnInfo{}
|
||||
|
||||
for groupByCol := range tempGroupBy {
|
||||
alias := selectAliases[groupByCol] // Will be "" if not in SELECT
|
||||
|
||||
// Extract originExpr by tracing back through queries
|
||||
originVisited := make(map[*clickhouse.SelectQuery]bool)
|
||||
originExpr := e.extractColumnOrigin(groupByCol, query, cteMap, originVisited)
|
||||
originField, err := extractCHOriginFieldFromQuery(fmt.Sprintf("SELECT %s", originExpr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, ColumnInfo{
|
||||
Name: groupByCol,
|
||||
Alias: alias,
|
||||
OriginExpr: originExpr,
|
||||
OriginField: originField,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// If no GROUP BY in this query, follow CTE/subquery references
|
||||
// It might have grouping inside the CTE/subquery
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
return e.extractGroupByColumns(sourceQuery, cteMap, visited)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// fillGroupsFromGroupByClause extracts GROUP BY columns from a specific GroupByClause and fills the map with the column names
|
||||
func (e *ClickHouseFilterExtractor) fillGroupsFromGroupByClause(groupByClause *clickhouse.GroupByClause, groupBy map[string]bool) {
|
||||
|
||||
// Extract GROUP BY expressions properly
|
||||
// Find only the direct child ColumnExprList, not nested ones
|
||||
// We use Find instead of FindAll to get only the first (direct child) ColumnExprList
|
||||
exprListNode, foundList := clickhouse.Find(groupByClause, func(node clickhouse.Expr) bool {
|
||||
_, ok := node.(*clickhouse.ColumnExprList)
|
||||
return ok
|
||||
})
|
||||
|
||||
if !foundList {
|
||||
return
|
||||
}
|
||||
|
||||
// Note: We only extract from the top-level ColumnExprList.Items to avoid extracting nested parts
|
||||
// This prevents extracting 'timestamp' from 'toDate(timestamp)' - we only get 'toDate(timestamp)'
|
||||
if exprList, ok := exprListNode.(*clickhouse.ColumnExprList); ok {
|
||||
// Extract each expression from the list - these are top-level only
|
||||
if exprList.Items != nil {
|
||||
for _, item := range exprList.Items {
|
||||
groupKey := e.extractColumnStrByExpr(item)
|
||||
if groupKey != "" {
|
||||
// Strip table alias if present (e.g., "m.region" -> "region")
|
||||
groupKey = e.stripTableAlias(groupKey)
|
||||
groupBy[groupKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// extractColumnStrByExpr extracts the complete string representation of different expression types
|
||||
// Supports:
|
||||
// - Ident: Simple identifier like "region" or "timestamp"
|
||||
// - FunctionExpr: Function call like "toDate(timestamp)"
|
||||
// - ColumnExpr: Column expression like "m.region", "toDate(timestamp)"
|
||||
// - Other expression types: Return the string representation of the expression
|
||||
//
|
||||
// For example:
|
||||
// - "region" -> "region"
|
||||
// - "toDate(timestamp)" -> "toDate(timestamp)"
|
||||
// - "`m.region`" -> "`m.region`"
|
||||
func (e *ClickHouseFilterExtractor) extractColumnStrByExpr(expr clickhouse.Expr) string {
|
||||
if expr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch ex := expr.(type) {
|
||||
// Ident is a simple identifier like "region" or "timestamp"
|
||||
case *clickhouse.Ident:
|
||||
// Handling for backticks which are native to ClickHouse and used for literal names.
|
||||
// CH Parser removes the backticks from the identifier, so we need to add them back.
|
||||
if ex.QuoteType == clickhouse.BackTicks {
|
||||
return "`" + ex.Name + "`"
|
||||
}
|
||||
return ex.Name
|
||||
// FunctionExpr is a function call like "toDate(timestamp)"
|
||||
case *clickhouse.FunctionExpr:
|
||||
// For function expressions, return the complete function call string
|
||||
return ex.String()
|
||||
// ColumnExpr is a column expression like "m.region", "toDate(timestamp)"
|
||||
case *clickhouse.ColumnExpr:
|
||||
// ColumnExpr wraps another expression - extract the underlying expression
|
||||
if ex.Expr != nil {
|
||||
return e.extractColumnStrByExpr(ex.Expr)
|
||||
}
|
||||
return ex.String()
|
||||
default:
|
||||
// For other expression types, return the string representation
|
||||
return expr.String()
|
||||
}
|
||||
}
|
||||
|
||||
// stripTableAlias removes table alias prefix from a column name (e.g., "m.region" -> "region")
|
||||
// but for literals with backticks, we need preserve the entire string. (e.g., `os.type` -> "os.type")
|
||||
func (e *ClickHouseFilterExtractor) stripTableAlias(name string) string {
|
||||
// Handling for backticks which are native to ClickHouse and used for literal names.
|
||||
if strings.HasPrefix(name, "`") && strings.HasSuffix(name, "`") {
|
||||
return strings.Trim(name, "`")
|
||||
}
|
||||
|
||||
// split the name by dot and return the last part
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// getColumnName extracts column name from an expression
|
||||
func (e *ClickHouseFilterExtractor) getColumnName(expr clickhouse.Expr) string {
|
||||
switch ex := expr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
return ex.Name
|
||||
case *clickhouse.Path:
|
||||
// Handle Path type for qualified column names like "m.metric_name"
|
||||
// Extract the last field which is the column name
|
||||
if len(ex.Fields) > 0 {
|
||||
return ex.Fields[len(ex.Fields)-1].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractSourceQuery extracts the SelectQuery from FROM expressions
|
||||
// Handles CTE references, subqueries, and table expressions
|
||||
// For example: from the below query We'll try to extract the name of the source query
|
||||
// which in the below case is "aggregated". Once we find it we return the SelectQuery node
|
||||
// from the cteMap, which acts as the source for the GROUP BY extraction.
|
||||
//
|
||||
// WITH aggregated AS (
|
||||
// SELECT region as region_alias, sum(value) AS total
|
||||
// FROM metrics
|
||||
// WHERE metric_name = 'cpu_usage'
|
||||
// GROUP BY region
|
||||
// )
|
||||
// SELECT * FROM aggregated
|
||||
func (e *ClickHouseFilterExtractor) extractSourceQuery(query *clickhouse.SelectQuery, cteMap map[string]*clickhouse.SelectQuery) *clickhouse.SelectQuery {
|
||||
if query.From == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the FROM clause and extract the source
|
||||
fromExprs := clickhouse.FindAll(query.From, func(node clickhouse.Expr) bool {
|
||||
switch node.(type) {
|
||||
case *clickhouse.Ident, *clickhouse.SelectQuery:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
for _, fromExpr := range fromExprs {
|
||||
switch expr := fromExpr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
// CTE reference by simple name
|
||||
if cteQuery, exists := cteMap[expr.Name]; exists {
|
||||
return cteQuery
|
||||
}
|
||||
case *clickhouse.SelectQuery:
|
||||
// Direct subquery
|
||||
return expr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Column Origin Tracing
|
||||
// ========================================
|
||||
|
||||
// extractColumnOrigin recursively traces a column back to its original expression
|
||||
// Returns the original expression string (e.g., "JSONExtractString(labels, 'service.name')")
|
||||
// or the column name itself if it's a direct column reference
|
||||
func (e *ClickHouseFilterExtractor) extractColumnOrigin(
|
||||
columnName string,
|
||||
query *clickhouse.SelectQuery,
|
||||
cteMap map[string]*clickhouse.SelectQuery,
|
||||
visited map[*clickhouse.SelectQuery]bool,
|
||||
) string {
|
||||
if query == nil {
|
||||
return columnName
|
||||
}
|
||||
|
||||
// Prevent infinite recursion and redundant work
|
||||
// Once a query is visited, we don't need to check it again
|
||||
if visited[query] {
|
||||
return columnName
|
||||
}
|
||||
visited[query] = true
|
||||
// this is to prevent infinite recursion in a single query search
|
||||
// but we don't want this to affect the other queries searches
|
||||
// so we delete it after the search is done for current query
|
||||
defer delete(visited, query)
|
||||
|
||||
// Step 1: Search in CTE and Joins, this will take us to very end of the SubQueries and CTE
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
returningOrigin := e.extractColumnOrigin(columnName, sourceQuery, cteMap, visited)
|
||||
if returningOrigin != columnName {
|
||||
return returningOrigin
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Once we're sure there are no SubQueries and CTE we just find all the selectItem
|
||||
// and then get their column origin values
|
||||
selectItems := clickhouse.FindAll(query, func(node clickhouse.Expr) bool {
|
||||
_, ok := node.(*clickhouse.SelectItem)
|
||||
return ok
|
||||
})
|
||||
|
||||
// extractOriginFromSelectItem extracts the origin from a SelectItem
|
||||
extractOriginFromSelectItem := func(selectItem *clickhouse.SelectItem) *string {
|
||||
// Check if this SelectItem matches our column (by alias or by name)
|
||||
alias := e.extractSelectItemAlias(selectItem)
|
||||
exprStr := e.extractSelectItemName(selectItem)
|
||||
normalizedExpr := e.stripTableAlias(exprStr)
|
||||
|
||||
// Case 1: Column matches an alias in SELECT
|
||||
if alias == columnName {
|
||||
// This is an alias - get the expression it's aliasing
|
||||
if selectItem.Expr != nil {
|
||||
originExpr := e.extractFullExpression(selectItem.Expr)
|
||||
// If the expression is just a column name, trace it back further
|
||||
if normalizedExpr == columnName || e.isSimpleColumnReference(selectItem.Expr) {
|
||||
// It's referencing another column - trace back through source query
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
originExpr := e.extractColumnOrigin(normalizedExpr, sourceQuery, cteMap, visited)
|
||||
return &originExpr
|
||||
}
|
||||
}
|
||||
return &originExpr
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Column matches the expression itself (no alias)
|
||||
if normalizedExpr == columnName {
|
||||
// Check if this is a simple column reference or a complex expression
|
||||
if e.isSimpleColumnReference(selectItem.Expr) {
|
||||
// Simple column - trace back through source query
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
originExpr := e.extractColumnOrigin(columnName, sourceQuery, cteMap, visited)
|
||||
return &originExpr
|
||||
}
|
||||
return &columnName
|
||||
} else {
|
||||
// Complex expression - return it as origin
|
||||
originExpr := e.extractFullExpression(selectItem.Expr)
|
||||
return &originExpr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var finalColumnOrigin string
|
||||
for _, itemNode := range selectItems {
|
||||
if selectItem, ok := itemNode.(*clickhouse.SelectItem); ok {
|
||||
// We call the extractOriginFromSelectItem function for each SelectItem
|
||||
// and if the origin is not nil, we set the finalColumnOrigin to the origin
|
||||
// this has to be done to get to the most nested origin of column where selectItem is present
|
||||
origin := extractOriginFromSelectItem(selectItem)
|
||||
if origin != nil {
|
||||
finalColumnOrigin = *origin
|
||||
}
|
||||
}
|
||||
}
|
||||
if finalColumnOrigin != "" {
|
||||
return finalColumnOrigin
|
||||
}
|
||||
|
||||
return columnName
|
||||
}
|
||||
|
||||
// extractFullExpression extracts the complete string representation of an expression
|
||||
func (e *ClickHouseFilterExtractor) extractFullExpression(expr clickhouse.Expr) string {
|
||||
if expr == nil {
|
||||
return ""
|
||||
}
|
||||
return expr.String()
|
||||
}
|
||||
|
||||
// isSimpleColumnReference checks if an expression is just a simple column reference
|
||||
// (not a function call or complex expression)
|
||||
func (e *ClickHouseFilterExtractor) isSimpleColumnReference(expr clickhouse.Expr) bool {
|
||||
if expr == nil {
|
||||
return false
|
||||
}
|
||||
switch ex := expr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
// backticks are treated as non simple column reference
|
||||
// so that we can return the origin expression with backticks
|
||||
// origin parser will handle the backticks and extract the column name from it
|
||||
if ex.QuoteType == clickhouse.BackTicks {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case *clickhouse.Path:
|
||||
return true
|
||||
case *clickhouse.ColumnExpr:
|
||||
// Check if it wraps a simple reference
|
||||
if ex.Expr != nil {
|
||||
return e.isSimpleColumnReference(ex.Expr)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SELECT Column Alias Extraction
|
||||
// ========================================
|
||||
|
||||
// extractSelectColumns extracts column names and their aliases from SELECT clause of a specific query
|
||||
// Returns a map where key is normalized column name and value is the alias
|
||||
// For duplicate columns with different aliases, the last alias wins
|
||||
// This follows the same pattern as extractGroupFromGroupByClause - finding direct children only
|
||||
func (e *ClickHouseFilterExtractor) extractSelectColumns(query *clickhouse.SelectQuery) map[string]string {
|
||||
aliasMap := make(map[string]string)
|
||||
|
||||
if query == nil {
|
||||
return aliasMap
|
||||
}
|
||||
|
||||
// Find SelectItem nodes which represent columns in the SELECT clause
|
||||
// SelectItem has an Expr field (the column/expression) and an Alias field
|
||||
selectItems := clickhouse.FindAll(query, func(node clickhouse.Expr) bool {
|
||||
_, ok := node.(*clickhouse.SelectItem)
|
||||
return ok
|
||||
})
|
||||
|
||||
// Process each SelectItem and extract column name and alias
|
||||
for _, itemNode := range selectItems {
|
||||
if selectItem, ok := itemNode.(*clickhouse.SelectItem); ok {
|
||||
// Extract the column name/expression from SelectItem.Expr
|
||||
columnName := e.extractSelectItemName(selectItem)
|
||||
if columnName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize column name (strip table alias)
|
||||
normalizedName := e.stripTableAlias(columnName)
|
||||
|
||||
// Extract alias from SelectItem.Alias
|
||||
alias := e.extractSelectItemAlias(selectItem)
|
||||
|
||||
// Store in map - last alias wins for duplicates
|
||||
aliasMap[normalizedName] = alias
|
||||
}
|
||||
}
|
||||
|
||||
return aliasMap
|
||||
}
|
||||
|
||||
// extractSelectItemName extracts the column name or expression from a SelectItem
|
||||
func (e *ClickHouseFilterExtractor) extractSelectItemName(selectItem *clickhouse.SelectItem) string {
|
||||
if selectItem == nil || selectItem.Expr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.extractColumnStrByExpr(selectItem.Expr)
|
||||
}
|
||||
|
||||
// extractSelectItemAlias extracts the alias from a SelectItem
|
||||
// Returns empty string if no alias is present
|
||||
func (e *ClickHouseFilterExtractor) extractSelectItemAlias(selectItem *clickhouse.SelectItem) string {
|
||||
if selectItem == nil || selectItem.Alias == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// The Alias field is an *Ident (pointer type)
|
||||
if selectItem.Alias.Name != "" {
|
||||
return selectItem.Alias.Name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CTE and Subquery Extraction
|
||||
// ========================================
|
||||
|
||||
// buildCTEMap builds a map of CTE names to their SelectQuery nodes by recursively
|
||||
// traversing all queries and their nested expressions
|
||||
func (e *ClickHouseFilterExtractor) buildCTEMap(query *clickhouse.SelectQuery, cteMap map[string]*clickhouse.SelectQuery) {
|
||||
|
||||
// Access CTEs directly from WithClause if it exists
|
||||
if query.With != nil && query.With.CTEs != nil {
|
||||
for _, cte := range query.With.CTEs {
|
||||
cteName := e.extractCTEName(cte)
|
||||
cteQuery := e.extractCTEQuery(cte)
|
||||
if cteName != "" && cteQuery != nil {
|
||||
cteMap[cteName] = cteQuery
|
||||
// Recursively build CTE map for nested CTEs
|
||||
e.buildCTEMap(cteQuery, cteMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for CTEs in subqueries and other expressions
|
||||
e.buildCTEMapFromExpr(query, cteMap)
|
||||
}
|
||||
|
||||
// extractCTEName extracts the CTE name from a CTEStmt, the Expr field is the name of the CTE
|
||||
func (e *ClickHouseFilterExtractor) extractCTEName(cte *clickhouse.CTEStmt) string {
|
||||
if cte == nil || cte.Expr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch name := cte.Expr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
return name.Name
|
||||
default:
|
||||
return cte.Expr.String()
|
||||
}
|
||||
}
|
||||
|
||||
// extractCTEQuery extracts the SelectQuery from a CTEStmt, the Alias field is the SelectQuery
|
||||
func (e *ClickHouseFilterExtractor) extractCTEQuery(cte *clickhouse.CTEStmt) *clickhouse.SelectQuery {
|
||||
if cte == nil || cte.Alias == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Alias field should contain a SelectQuery
|
||||
if selectQuery, ok := cte.Alias.(*clickhouse.SelectQuery); ok {
|
||||
return selectQuery
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCTEMapFromExpr recursively extracts CTEs from various expression types
|
||||
func (e *ClickHouseFilterExtractor) buildCTEMapFromExpr(expr clickhouse.Expr, cteMap map[string]*clickhouse.SelectQuery) {
|
||||
|
||||
// Walk through all nodes to find SelectQuery nodes that might contain CTEs
|
||||
clickhouse.Walk(expr, func(node clickhouse.Expr) bool {
|
||||
switch n := node.(type) {
|
||||
case *clickhouse.SelectQuery:
|
||||
// Don't process the same query we started with to avoid infinite recursion
|
||||
if n != expr {
|
||||
e.buildCTEMap(n, cteMap)
|
||||
}
|
||||
case *clickhouse.TableExpr:
|
||||
if n.Expr != nil {
|
||||
e.buildCTEMapFromExpr(n.Expr, cteMap)
|
||||
}
|
||||
case *clickhouse.JoinTableExpr:
|
||||
if n.Table != nil {
|
||||
e.buildCTEMapFromExpr(n.Table, cteMap)
|
||||
}
|
||||
}
|
||||
return true // Continue traversal
|
||||
})
|
||||
}
|
||||
305
pkg/parser/queryfilterextractor/clickhouse_originparser.go
Normal file
305
pkg/parser/queryfilterextractor/clickhouse_originparser.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// excludedFunctions contains functions that should cause ExtractOriginField to return empty string.
|
||||
// Map key is the function name in lowercase, value is the original function name.
|
||||
var excludedFunctions = map[string]string{
|
||||
// Time functions
|
||||
"now": "now",
|
||||
"today": "today",
|
||||
"yesterday": "yesterday",
|
||||
"todatetime": "toDateTime",
|
||||
"todatetime64": "toDateTime64",
|
||||
"todate": "toDate",
|
||||
"todate32": "toDate32",
|
||||
"tostartofinterval": "toStartOfInterval",
|
||||
"tostartofday": "toStartOfDay",
|
||||
"tostartofweek": "toStartOfWeek",
|
||||
"tostartofmonth": "toStartOfMonth",
|
||||
"tostartofquarter": "toStartOfQuarter",
|
||||
"tostartofyear": "toStartOfYear",
|
||||
"tostartofhour": "toStartOfHour",
|
||||
"tostartofminute": "toStartOfMinute",
|
||||
"tostartofsecond": "toStartOfSecond",
|
||||
"tostartoffiveminutes": "toStartOfFiveMinutes",
|
||||
"tostartoftenminutes": "toStartOfTenMinutes",
|
||||
"tostartoffifteenminutes": "toStartOfFifteenMinutes",
|
||||
"tointervalsecond": "toIntervalSecond",
|
||||
"tointervalminute": "toIntervalMinute",
|
||||
"tointervalhour": "toIntervalHour",
|
||||
"tointervalday": "toIntervalDay",
|
||||
"tointervalweek": "toIntervalWeek",
|
||||
"tointervalmonth": "toIntervalMonth",
|
||||
"tointervalquarter": "toIntervalQuarter",
|
||||
"tointervalyear": "toIntervalYear",
|
||||
"parsedatetime": "parseDateTime",
|
||||
"parsedatetimebesteffort": "parseDateTimeBestEffort",
|
||||
|
||||
// Aggregate functions
|
||||
"count": "count",
|
||||
"sum": "sum",
|
||||
"avg": "avg",
|
||||
"min": "min",
|
||||
"max": "max",
|
||||
"any": "any",
|
||||
"stddevpop": "stddevPop",
|
||||
"stddevsamp": "stddevSamp",
|
||||
"varpop": "varPop",
|
||||
"varsamp": "varSamp",
|
||||
"grouparray": "groupArray",
|
||||
"groupuniqarray": "groupUniqArray",
|
||||
"quantile": "quantile",
|
||||
"quantiles": "quantiles",
|
||||
"quantileexact": "quantileExact",
|
||||
"quantiletiming": "quantileTiming",
|
||||
"median": "median",
|
||||
"uniq": "uniq",
|
||||
"uniqexact": "uniqExact",
|
||||
"uniqcombined": "uniqCombined",
|
||||
"uniqhll12": "uniqHLL12",
|
||||
"topk": "topK",
|
||||
"first": "first",
|
||||
"last": "last",
|
||||
}
|
||||
|
||||
// jsonExtractFunctions contains functions that extract from JSON columns.
|
||||
// Map key is the function name in lowercase, value is the original function name.
|
||||
var jsonExtractFunctions = map[string]string{
|
||||
"jsonextractstring": "JSONExtractString",
|
||||
"jsonextractint": "JSONExtractInt",
|
||||
"jsonextractuint": "JSONExtractUInt",
|
||||
"jsonextractfloat": "JSONExtractFloat",
|
||||
"jsonextractbool": "JSONExtractBool",
|
||||
"jsonextract": "JSONExtract",
|
||||
"jsonextractraw": "JSONExtractRaw",
|
||||
"jsonextractarrayraw": "JSONExtractArrayRaw",
|
||||
"jsonextractkeysandvalues": "JSONExtractKeysAndValues",
|
||||
}
|
||||
|
||||
// isFunctionPresentInStore checks if a function name exists in the function store map
|
||||
func isFunctionPresentInStore(funcName string, funcStore map[string]string) bool {
|
||||
_, exists := funcStore[strings.ToLower(funcName)]
|
||||
return exists
|
||||
}
|
||||
|
||||
// isReservedSelectKeyword checks if a keyword is a reserved keyword for the SELECT statement
|
||||
// We're only including those which can appear in the SELECT statement without being quoted
|
||||
func isReservedSelectKeyword(keyword string) bool {
|
||||
return strings.ToUpper(keyword) == parser.KeywordSelect || strings.ToUpper(keyword) == parser.KeywordFrom
|
||||
}
|
||||
|
||||
// extractCHOriginField extracts the origin field (column name) from a query string
|
||||
// or fields getting extracted in case of JSON extraction functions.
|
||||
func extractCHOriginFieldFromQuery(query string) (string, error) {
|
||||
// Parse the query string
|
||||
p := parser.NewParser(query)
|
||||
stmts, err := p.ParseStmts()
|
||||
if err != nil {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "failed to parse origin field from query: %s", err.Error())
|
||||
}
|
||||
|
||||
// Get the first statement which should be a SELECT
|
||||
selectStmt := stmts[0].(*parser.SelectQuery)
|
||||
|
||||
// If query has multiple select items, return blank string as we don't expect multiple select items
|
||||
if len(selectStmt.SelectItems) > 1 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if len(selectStmt.SelectItems) == 0 {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "SELECT query has no select items")
|
||||
}
|
||||
|
||||
// Extract origin field from the first (and only) select item's expression
|
||||
return extractOriginFieldFromExpr(selectStmt.SelectItems[0].Expr)
|
||||
}
|
||||
|
||||
// extractOriginFieldFromExpr extracts the origin field (column name) from an expression.
|
||||
// This is the internal helper function that contains the original logic.
|
||||
func extractOriginFieldFromExpr(expr parser.Expr) (string, error) {
|
||||
// Check if expression contains excluded functions or IF/CASE
|
||||
hasExcludedExpressions := false
|
||||
hasReservedKeyword := false
|
||||
|
||||
parser.Walk(expr, func(node parser.Expr) bool {
|
||||
// exclude reserved keywords because the parser will treat them as valid SQL
|
||||
// example: SELECT FROM table here the "FROM" is a reserved keyword,
|
||||
// but the parser will treat it as valid column to be extracted.
|
||||
if ident, ok := node.(*parser.Ident); ok {
|
||||
if ident.QuoteType == parser.Unquoted && isReservedSelectKeyword(ident.Name) {
|
||||
hasReservedKeyword = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
// for functions, we need to check if the function is excluded function or a JSON extraction function with nested JSON extraction
|
||||
if funcExpr, ok := node.(*parser.FunctionExpr); ok {
|
||||
if isFunctionPresentInStore(funcExpr.Name.Name, excludedFunctions) {
|
||||
hasExcludedExpressions = true
|
||||
return false
|
||||
}
|
||||
// Check for nested JSON extraction functions
|
||||
if isFunctionPresentInStore(funcExpr.Name.Name, jsonExtractFunctions) {
|
||||
// Check if any argument contains another JSON extraction function
|
||||
if funcExpr.Params != nil && funcExpr.Params.Items != nil {
|
||||
for _, arg := range funcExpr.Params.Items.Items {
|
||||
if containsJSONExtractFunction(arg) {
|
||||
hasExcludedExpressions = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := node.(*parser.CaseExpr); ok {
|
||||
hasExcludedExpressions = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// If the expression contains reserved keywords, return error
|
||||
if hasReservedKeyword {
|
||||
return "", errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "reserved keyword found in select clause")
|
||||
}
|
||||
|
||||
// If the expression contains excluded expressions, return empty string
|
||||
if hasExcludedExpressions {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Extract all column names from the expression
|
||||
columns := extractColumns(expr)
|
||||
|
||||
// If we found exactly one unique column, return it
|
||||
if len(columns) == 1 {
|
||||
return columns[0], nil
|
||||
}
|
||||
|
||||
// Multiple columns or no columns - return empty string
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// containsJSONExtractFunction checks if an expression contains a JSON extraction function
|
||||
func containsJSONExtractFunction(expr parser.Expr) bool {
|
||||
if expr == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
found := false
|
||||
parser.Walk(expr, func(node parser.Expr) bool {
|
||||
if funcExpr, ok := node.(*parser.FunctionExpr); ok {
|
||||
if isFunctionPresentInStore(funcExpr.Name.Name, jsonExtractFunctions) {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// extractColumns recursively extracts all unique column names from an expression.
|
||||
// Note: String literals are also considered as origin fields and will be included in the result.
|
||||
func extractColumns(expr parser.Expr) []string {
|
||||
|
||||
columnMap := make(map[string]bool)
|
||||
extractColumnsHelper(expr, columnMap)
|
||||
|
||||
// Convert map to slice
|
||||
columns := make([]string, 0, len(columnMap))
|
||||
for col := range columnMap {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// extractColumnsHelper is a recursive helper that finds all column references.
|
||||
// Note: String literals are also considered as origin fields and will be added to the columnMap.
|
||||
func extractColumnsHelper(expr parser.Expr, columnMap map[string]bool) {
|
||||
switch n := expr.(type) {
|
||||
// Ident is a simple identifier like "region" or "timestamp"
|
||||
case *parser.Ident:
|
||||
// Add identifiers as column references
|
||||
columnMap[n.Name] = true
|
||||
|
||||
// FunctionExpr is a function call like "toDate(timestamp)", "JSONExtractString(labels, 'service.name')"
|
||||
case *parser.FunctionExpr:
|
||||
// Special handling for JSON extraction functions
|
||||
// In case of nested JSON extraction, we return blank values (handled at top level)
|
||||
if isFunctionPresentInStore(n.Name.Name, jsonExtractFunctions) {
|
||||
// For JSON functions, extract from the second argument (the JSON path/key being extracted)
|
||||
// The first argument is the column name, the second is the exact data being extracted
|
||||
// The extracted data (second argument) is treated as the origin field
|
||||
if n.Params != nil && n.Params.Items != nil && len(n.Params.Items.Items) >= 2 {
|
||||
secondArg := n.Params.Items.Items[1]
|
||||
// If the second argument is a string literal, use its value as the origin field
|
||||
// String literals are considered as origin fields
|
||||
if strLit, ok := secondArg.(*parser.StringLiteral); ok {
|
||||
columnMap[strLit.Literal] = true
|
||||
} else {
|
||||
// Otherwise, try to extract columns from it
|
||||
extractColumnsHelper(secondArg, columnMap)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For regular functions, recursively process all arguments, ex: lower(name)
|
||||
if n.Params != nil && n.Params.Items != nil {
|
||||
for _, item := range n.Params.Items.Items {
|
||||
extractColumnsHelper(item, columnMap)
|
||||
}
|
||||
}
|
||||
|
||||
// BinaryOperation is a binary operation like "region = 'us-east-1'" or "unix_milli / 1000"
|
||||
case *parser.BinaryOperation:
|
||||
extractColumnsHelper(n.LeftExpr, columnMap)
|
||||
extractColumnsHelper(n.RightExpr, columnMap)
|
||||
|
||||
// ColumnExpr is a column expression like "m.region", "service.name"
|
||||
case *parser.ColumnExpr:
|
||||
extractColumnsHelper(n.Expr, columnMap)
|
||||
|
||||
// CastExpr is a cast expression like "CAST(unix_milli AS String)"
|
||||
case *parser.CastExpr:
|
||||
extractColumnsHelper(n.Expr, columnMap)
|
||||
|
||||
case *parser.ParamExprList:
|
||||
if n.Items != nil {
|
||||
extractColumnsHelper(n.Items, columnMap)
|
||||
}
|
||||
|
||||
// Ex: coalesce(cpu_usage, 0) + coalesce(mem_usage, 0)
|
||||
case *parser.ColumnExprList:
|
||||
for _, item := range n.Items {
|
||||
extractColumnsHelper(item, columnMap)
|
||||
}
|
||||
|
||||
// StringLiteral is a string literal like "us-east-1" or "cpu.usage"
|
||||
case *parser.StringLiteral:
|
||||
// String literals are considered as origin fields
|
||||
columnMap[n.Literal] = true
|
||||
return
|
||||
|
||||
// Support for columns like table.column_name
|
||||
case *parser.Path:
|
||||
if len(n.Fields) > 0 {
|
||||
extractColumnsHelper(n.Fields[len(n.Fields)-1], columnMap)
|
||||
}
|
||||
return
|
||||
|
||||
// Add more cases as needed for other expression types
|
||||
|
||||
default:
|
||||
// For unknown types, return empty (don't extract columns)
|
||||
return
|
||||
}
|
||||
}
|
||||
237
pkg/parser/queryfilterextractor/clickhouse_originparser_test.go
Normal file
237
pkg/parser/queryfilterextractor/clickhouse_originparser_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractOriginField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
// JSON extraction functions - should return the second argument (JSON path/key) as origin field
|
||||
{
|
||||
name: "JSONExtractString simple",
|
||||
query: `SELECT JSONExtractString(labels, 'service.name')`,
|
||||
expected: "service.name",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractInt",
|
||||
query: `SELECT JSONExtractInt(labels, 'status.code')`,
|
||||
expected: "status.code",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractFloat",
|
||||
query: `SELECT JSONExtractFloat(labels, 'cpu.usage')`,
|
||||
expected: "cpu.usage",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractBool",
|
||||
query: `SELECT JSONExtractBool(labels, 'feature.enabled')`,
|
||||
expected: "feature.enabled",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractString with function wrapper",
|
||||
query: `SELECT lower(JSONExtractString(labels, 'user.email'))`,
|
||||
expected: "user.email",
|
||||
},
|
||||
{
|
||||
name: "Nested JSON extraction",
|
||||
query: `SELECT JSONExtractInt(JSONExtractRaw(labels, 'meta'), 'status.code')`,
|
||||
expected: "", // Nested JSON extraction should return blank
|
||||
},
|
||||
|
||||
// Nested functions - should return the deepest column
|
||||
{
|
||||
name: "Nested time functions with column",
|
||||
query: `SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60))`,
|
||||
expected: "", // Contains toStartOfInterval and toDateTime which are excluded
|
||||
},
|
||||
{
|
||||
name: "Division with column",
|
||||
query: `SELECT unix_milli / 1000`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
{
|
||||
name: "Function with single column",
|
||||
query: `SELECT lower(unix_milli)`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
{
|
||||
name: "CAST with single column",
|
||||
query: `SELECT CAST(unix_milli AS String)`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
{
|
||||
name: "intDiv with single column",
|
||||
query: `SELECT intDiv(unix_milli, 1000)`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
|
||||
// Multiple columns - should return blank
|
||||
{
|
||||
name: "Multiple columns in coalesce",
|
||||
query: `SELECT (coalesce(cpu_usage, 0) + coalesce(mem_usage, 0)) / 2`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple columns in arithmetic",
|
||||
query: `SELECT cpu_usage + mem_usage`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple columns in function",
|
||||
query: `SELECT concat(first_name, last_name)`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// IF/CASE conditions - should return blank
|
||||
{
|
||||
name: "IF with single column in condition",
|
||||
query: `SELECT IF(error_count > 0, service, 'healthy')`,
|
||||
expected: "", // Multiple columns: error_count and service
|
||||
},
|
||||
{
|
||||
name: "IF with JSON and multiple columns",
|
||||
query: `SELECT if(JSONExtractInt(metadata, 'retry.count') > 3, toLower(JSONExtractString(metadata, 'user.id')), hostname)`,
|
||||
expected: "", // Multiple columns: metadata and hostname
|
||||
},
|
||||
{
|
||||
name: "String literal should return string",
|
||||
query: `SELECT 'constant'`,
|
||||
expected: "constant",
|
||||
},
|
||||
|
||||
// No columns - should return blank
|
||||
{
|
||||
name: "Number literal",
|
||||
query: `SELECT 42`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple literals",
|
||||
query: `SELECT 'constant', 42`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple string literals",
|
||||
query: `SELECT 'constant', '42'`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// Excluded functions - should return blank
|
||||
{
|
||||
name: "now() function",
|
||||
query: `SELECT now()`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "today() function",
|
||||
query: `SELECT today()`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "count aggregate",
|
||||
query: `SELECT count(user_id)`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "sum aggregate",
|
||||
query: `SELECT sum(amount)`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// Single column simple cases
|
||||
{
|
||||
name: "Simple column reference",
|
||||
query: `SELECT user_id`,
|
||||
expected: "user_id",
|
||||
},
|
||||
{
|
||||
name: "Column with alias",
|
||||
query: `SELECT user_id AS id`,
|
||||
expected: "user_id",
|
||||
},
|
||||
{
|
||||
name: "Column in arithmetic with literals (multiplication)",
|
||||
query: `SELECT unix_milli * 1000`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "Nested functions with single column deep",
|
||||
query: `SELECT upper(lower(trim(column_name)))`,
|
||||
expected: "column_name",
|
||||
},
|
||||
// Qualified column names (Path)
|
||||
{
|
||||
name: "Column with table prefix",
|
||||
query: `SELECT table.column_name`,
|
||||
expected: "column_name", // IndexOperation: extracts column name from Index field
|
||||
},
|
||||
{
|
||||
name: "Qualified column in function",
|
||||
query: `SELECT lower(table.column_name)`,
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Qualified column in arithmetic",
|
||||
query: `SELECT table.column_name * 100`,
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Nested qualified column (schema.table.column)",
|
||||
query: `SELECT schema.table.column_name`,
|
||||
expected: "column_name", // Should extract the final column name
|
||||
},
|
||||
{
|
||||
name: "Multiple qualified columns",
|
||||
query: `SELECT table1.column1 + table2.column2`,
|
||||
expected: "", // Multiple columns: column1 and column2
|
||||
},
|
||||
{
|
||||
name: "Qualified column with CAST",
|
||||
query: `SELECT CAST(table.column_name AS String)`,
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Multiple select items - return blank",
|
||||
query: `SELECT JSONExtractString(labels, 'service.name'), unix_milli / 1000, cpu_usage + mem_usage`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "Invalid SQL syntax",
|
||||
query: `SELECT FROM table`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed query",
|
||||
query: `SELECT * FROM`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := extractCHOriginFieldFromQuery(tt.query)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ExtractOriginField() expected error but got nil, result = %q", result)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ExtractOriginField() unexpected error: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExtractOriginField() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1279
pkg/parser/queryfilterextractor/clickhouse_test.go
Normal file
1279
pkg/parser/queryfilterextractor/clickhouse_test.go
Normal file
File diff suppressed because it is too large
Load Diff
115
pkg/parser/queryfilterextractor/promql.go
Normal file
115
pkg/parser/queryfilterextractor/promql.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// PromQLFilterExtractor extracts metric names and grouping keys from PromQL queries
|
||||
type PromQLFilterExtractor struct{}
|
||||
|
||||
// NewPromQLFilterExtractor creates a new PromQL filter extractor
|
||||
func NewPromQLFilterExtractor() *PromQLFilterExtractor {
|
||||
return &PromQLFilterExtractor{}
|
||||
}
|
||||
|
||||
// Extract parses a PromQL query and extracts metric names and grouping keys
|
||||
func (e *PromQLFilterExtractor) Extract(query string) (*FilterResult, error) {
|
||||
expr, err := parser.ParseExpr(query)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse promql query: %s", err.Error())
|
||||
}
|
||||
|
||||
result := &FilterResult{
|
||||
MetricNames: []string{},
|
||||
GroupByColumns: []ColumnInfo{},
|
||||
}
|
||||
|
||||
// Use a visitor to traverse the AST
|
||||
visitor := &promQLVisitor{
|
||||
metricNames: make(map[string]bool),
|
||||
groupBy: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Walk the AST
|
||||
if err := parser.Walk(visitor, expr, nil); err != nil {
|
||||
return result, errors.NewInternalf(errors.CodeInternal, "failed to walk promql query: %s", err.Error())
|
||||
}
|
||||
|
||||
// Convert sets to slices
|
||||
for metric := range visitor.metricNames {
|
||||
result.MetricNames = append(result.MetricNames, metric)
|
||||
}
|
||||
for groupKey := range visitor.groupBy {
|
||||
result.GroupByColumns = append(result.GroupByColumns, ColumnInfo{Name: groupKey, OriginExpr: groupKey, OriginField: groupKey})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// promQLVisitor implements the parser.Visitor interface
|
||||
type promQLVisitor struct {
|
||||
metricNames map[string]bool
|
||||
groupBy map[string]bool
|
||||
// Track if we've already captured grouping from an outermost aggregation
|
||||
hasOutermostGrouping bool
|
||||
}
|
||||
|
||||
func (v *promQLVisitor) Visit(node parser.Node, path []parser.Node) (parser.Visitor, error) {
|
||||
switch n := node.(type) {
|
||||
case *parser.VectorSelector:
|
||||
v.visitVectorSelector(n)
|
||||
case *parser.AggregateExpr:
|
||||
v.visitAggregateExpr(n, path)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// visitVectorSelector will be called whenever the Visitor encounters a VectorSelector node.
|
||||
// in the case we'll be extracting the metric names from the vector selector.
|
||||
func (v *promQLVisitor) visitVectorSelector(vs *parser.VectorSelector) {
|
||||
// Check if metric name is specified directly
|
||||
if vs.Name != "" {
|
||||
v.metricNames[vs.Name] = true
|
||||
}
|
||||
|
||||
// Check for __name__ label matcher
|
||||
for _, matcher := range vs.LabelMatchers {
|
||||
if matcher.Name == labels.MetricName {
|
||||
switch matcher.Type {
|
||||
case labels.MatchEqual:
|
||||
v.metricNames[matcher.Value] = true
|
||||
// Skip for negative filters - negative filters don't extract metric names
|
||||
// case labels.MatchNotEqual, labels.MatchRegexp, labels.MatchNotRegexp:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visitAggregateExpr will be called whenever the Visitor encounters an AggregateExpr node.
|
||||
// in the case we'll be extracting the grouping keys from the outermost aggregation.
|
||||
func (v *promQLVisitor) visitAggregateExpr(ae *parser.AggregateExpr, path []parser.Node) {
|
||||
// Count how many AggregateExpr nodes are in the path (excluding current node)
|
||||
// This tells us the nesting level
|
||||
nestingLevel := 0
|
||||
for _, p := range path {
|
||||
if _, ok := p.(*parser.AggregateExpr); ok {
|
||||
nestingLevel++
|
||||
}
|
||||
}
|
||||
|
||||
// Only capture grouping from the outermost aggregation (nesting level 0)
|
||||
if nestingLevel == 0 && !v.hasOutermostGrouping {
|
||||
// If Without is true, we skip grouping per spec
|
||||
if !ae.Without && len(ae.Grouping) > 0 {
|
||||
v.hasOutermostGrouping = true
|
||||
for _, label := range ae.Grouping {
|
||||
v.groupBy[label] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue traversal to find metrics in the expression
|
||||
}
|
||||
205
pkg/parser/queryfilterextractor/promql_test.go
Normal file
205
pkg/parser/queryfilterextractor/promql_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPromQLFilterExtractor_Extract(t *testing.T) {
|
||||
extractor := NewPromQLFilterExtractor()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantMetrics []string
|
||||
wantGroupByColumns []ColumnInfo
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "P1 - Simple vector selector",
|
||||
query: `http_requests_total{job="api"}`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P2 - Function call",
|
||||
query: `rate(cpu_usage_seconds_total[5m])`,
|
||||
wantMetrics: []string{"cpu_usage_seconds_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P3 - Aggregation with by()",
|
||||
query: `sum by (pod,region) (rate(http_requests_total[5m]))`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "pod", OriginExpr: "pod", OriginField: "pod"}, {Name: "region", OriginExpr: "region", OriginField: "region"}},
|
||||
},
|
||||
{
|
||||
name: "P4 - Aggregation with without()",
|
||||
query: `sum without (instance) (rate(cpu_usage_total[1m]))`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{}, // without() means no grouping keys per spec
|
||||
},
|
||||
{
|
||||
name: "P5 - Invalid: metric name set twice",
|
||||
query: `sum(rate(http_requests_total{__name__!="http_requests_error_total"}[5m]))`,
|
||||
wantMetrics: []string{},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "P6 - Regex negative label",
|
||||
query: `sum(rate(http_requests_total{status!~"5.."}[5m]))`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P7 - Nested aggregations",
|
||||
query: `sum by (region) (max by (pod, region) (cpu_usage_total{env="prod"}))`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "region", OriginExpr: "region", OriginField: "region"}}, // Only outermost grouping
|
||||
},
|
||||
{
|
||||
name: "P7a - Nested aggregation: inner grouping ignored",
|
||||
query: `sum(max by (pod) (cpu_usage_total{env="prod"}))`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{}, // Inner grouping is ignored when outer has no grouping (nestingLevel != 0 case)
|
||||
},
|
||||
{
|
||||
name: "P8 - Arithmetic expression",
|
||||
query: `(http_requests_total{job="api"} + http_errors_total{job="api"})`,
|
||||
wantMetrics: []string{"http_requests_total", "http_errors_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P9 - Mix of positive metric & exclusion label",
|
||||
query: `sum by (region)(rate(foo{job!="db"}[5m]))`,
|
||||
wantMetrics: []string{"foo"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "region", OriginExpr: "region", OriginField: "region"}},
|
||||
},
|
||||
{
|
||||
name: "P10 - Function + aggregation",
|
||||
query: `histogram_quantile(0.9, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))`,
|
||||
wantMetrics: []string{"http_request_duration_seconds_bucket"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "le", OriginExpr: "le", OriginField: "le"}},
|
||||
},
|
||||
{
|
||||
name: "P11 - Subquery",
|
||||
query: `sum_over_time(cpu_usage_total[1h:5m])`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P12 - Nested aggregation inside subquery",
|
||||
query: `max_over_time(sum(rate(cpu_usage_total[5m]))[1h:5m])`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P13 - Subquery with multiple metrics",
|
||||
query: `avg_over_time((foo + bar)[10m:1m])`,
|
||||
wantMetrics: []string{"foo", "bar"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P14 - Simple meta-metric",
|
||||
query: `sum by (pod) (up)`,
|
||||
wantMetrics: []string{"up"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "pod", OriginExpr: "pod", OriginField: "pod"}},
|
||||
},
|
||||
{
|
||||
name: "P15 - Binary operator unless",
|
||||
query: `sum(rate(http_requests_total[5m])) unless avg(rate(http_errors_total[5m]))`,
|
||||
wantMetrics: []string{"http_requests_total", "http_errors_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P16 - Vector matching",
|
||||
query: `sum(rate(foo[5m])) / ignoring(instance) group_left(job) sum(rate(bar[5m]))`,
|
||||
wantMetrics: []string{"foo", "bar"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P17 - Offset modifier with aggregation",
|
||||
query: `sum by (env)(rate(cpu_usage_seconds_total{job="api"}[5m] offset 1h))`,
|
||||
wantMetrics: []string{"cpu_usage_seconds_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "env", OriginExpr: "env", OriginField: "env"}},
|
||||
},
|
||||
{
|
||||
name: "P18 - Invalid syntax",
|
||||
query: `sum by ((foo)(bar))(http_requests_total)`,
|
||||
wantMetrics: []string{},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "P19 - Literal expression",
|
||||
query: `2 + 3`,
|
||||
wantMetrics: []string{},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P20 - Aggregation inside subquery with deriv",
|
||||
query: `deriv(sum by (instance)(rate(node_network_receive_bytes_total[5m]))[30m:5m])`,
|
||||
wantMetrics: []string{"node_network_receive_bytes_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "instance", OriginExpr: "instance", OriginField: "instance"}}, // Aggregation is inside subquery, not outermost
|
||||
},
|
||||
{
|
||||
name: "P21 - Aggregation inside subquery with avg_over_time",
|
||||
query: `avg_over_time(sum by (job)(rate(http_requests_total[1m]))[30m:1m])`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "job", OriginExpr: "job", OriginField: "job"}}, // Aggregation is inside subquery, not outermost
|
||||
},
|
||||
{
|
||||
name: "P22 - Aggregation inside subquery with max_over_time",
|
||||
query: `max_over_time(sum by (pod)(rate(container_restarts_total[5m]))[1h:5m])`,
|
||||
wantMetrics: []string{"container_restarts_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "pod", OriginExpr: "pod", OriginField: "pod"}}, // Aggregation is inside subquery, not outermost
|
||||
},
|
||||
{
|
||||
name: "P23 - Aggregation inside subquery with deriv (no rate)",
|
||||
query: `deriv(sum by (namespace)(container_memory_working_set_bytes)[1h:10m])`,
|
||||
wantMetrics: []string{"container_memory_working_set_bytes"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "namespace", OriginExpr: "namespace", OriginField: "namespace"}}, // Aggregation is inside subquery, not outermost
|
||||
},
|
||||
{
|
||||
name: "P24 - Aggregation inside subquery with histogram_quantile",
|
||||
query: `histogram_quantile(0.95, avg_over_time(sum by (le, service)(rate(http_request_duration_seconds_bucket[5m]))[1h:5m]))`,
|
||||
wantMetrics: []string{"http_request_duration_seconds_bucket"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "le", OriginExpr: "le", OriginField: "le"}, {Name: "service", OriginExpr: "service", OriginField: "service"}}, // Aggregation is inside subquery, not outermost
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := extractor.Extract(tt.query)
|
||||
|
||||
// Check error expectation
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Errorf("Extract() expected error but got none, query: %s", tt.query)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Extract() unexpected error = %v, query: %s", err, tt.query)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort for comparison
|
||||
gotMetrics := sortStrings(result.MetricNames)
|
||||
wantMetrics := sortStrings(tt.wantMetrics)
|
||||
|
||||
if !reflect.DeepEqual(gotMetrics, wantMetrics) {
|
||||
t.Errorf("Extract() MetricNames = %v, want %v", gotMetrics, wantMetrics)
|
||||
}
|
||||
|
||||
// Test GroupByColumns - need to normalize for comparison (order may vary)
|
||||
gotGroupByColumns := sortColumnInfo(result.GroupByColumns)
|
||||
wantGroupByColumns := sortColumnInfo(tt.wantGroupByColumns)
|
||||
|
||||
if !reflect.DeepEqual(gotGroupByColumns, wantGroupByColumns) {
|
||||
t.Errorf("Extract() GroupByColumns = %v, want %v", gotGroupByColumns, wantGroupByColumns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
pkg/parser/queryfilterextractor/queryfilterextractor.go
Normal file
58
pkg/parser/queryfilterextractor/queryfilterextractor.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package queryfilterextractor provides utilities for extracting metric names
|
||||
// and grouping keys.
|
||||
//
|
||||
// This is useful for metrics discovery, and query analysis.
|
||||
package queryfilterextractor
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
const (
|
||||
ExtractorCH = "qfe_ch"
|
||||
ExtractorPromQL = "qfe_promql"
|
||||
)
|
||||
|
||||
// ColumnInfo represents a column in the query
|
||||
type ColumnInfo struct {
|
||||
Name string
|
||||
Alias string
|
||||
OriginExpr string
|
||||
OriginField string
|
||||
}
|
||||
|
||||
// GroupName returns the field name in the resulting data which is used for grouping
|
||||
//
|
||||
// - examples:
|
||||
//
|
||||
// - SELECT region as new_region FROM metrics WHERE metric_name='cpu' GROUP BY region
|
||||
// GroupName() will return "new_region"
|
||||
//
|
||||
// - SELECT region FROM metrics WHERE metric_name='cpu' GROUP BY region
|
||||
// GroupName() will return "region"
|
||||
func (c *ColumnInfo) GroupName() string {
|
||||
if c.Alias != "" {
|
||||
return c.Alias
|
||||
}
|
||||
return c.Name
|
||||
}
|
||||
|
||||
type FilterResult struct {
|
||||
// MetricNames are the metrics that are being filtered on
|
||||
MetricNames []string
|
||||
// GroupByColumns are the columns that are being grouped by
|
||||
GroupByColumns []ColumnInfo
|
||||
}
|
||||
|
||||
type FilterExtractor interface {
|
||||
Extract(query string) (*FilterResult, error)
|
||||
}
|
||||
|
||||
func NewExtractor(extractorType string) (FilterExtractor, error) {
|
||||
switch extractorType {
|
||||
case ExtractorCH:
|
||||
return NewClickHouseFilterExtractor(), nil
|
||||
case ExtractorPromQL:
|
||||
return NewPromQLFilterExtractor(), nil
|
||||
default:
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid extractor type: %s", extractorType)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
type promqlQuery struct {
|
||||
@@ -59,8 +60,50 @@ func (q *promqlQuery) Window() (uint64, uint64) {
|
||||
return q.tr.From, q.tr.To
|
||||
}
|
||||
|
||||
// removeAllVarMatchers removes label matchers from a PromQL query that reference variables with __all__ value.
|
||||
// This method parses the query, walks the AST to remove matching matchers, and returns the modified query string.
|
||||
// If parsing or walking fails, it returns an error.
|
||||
func (q *promqlQuery) removeAllVarMatchers(query string, vars map[string]qbv5.VariableItem) (string, error) {
|
||||
// Find all variables that have __all__ value
|
||||
allVars := make(map[string]bool)
|
||||
for k, v := range vars {
|
||||
if v.Type == qbv5.DynamicVariableType {
|
||||
if allVal, ok := v.Value.(string); ok && allVal == "__all__" {
|
||||
allVars[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no variables have __all__ value, return the query unchanged
|
||||
if len(allVars) == 0 {
|
||||
return query, nil
|
||||
}
|
||||
|
||||
expr, err := parser.ParseExpr(query)
|
||||
if err != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query %q", query)
|
||||
}
|
||||
|
||||
// Create visitor and walk the AST
|
||||
visitor := &allVarRemover{allVars: allVars}
|
||||
if err := parser.Walk(visitor, expr, nil); err != nil {
|
||||
q.logger.ErrorContext(context.TODO(), "unexpected error while removing __all__ variable matchers", "error", err, "query", query)
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "error while removing __all__ variable matchers")
|
||||
}
|
||||
|
||||
// Convert the modified AST back to a string
|
||||
return expr.String(), nil
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): cleanup the templating logic
|
||||
func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem, start, end uint64) (string, error) {
|
||||
// First, remove label matchers that use variables with __all__ value.
|
||||
// This must happen before variable substitution so we can detect variable references
|
||||
// in their original form ($var, {{var}}, [[var]]).
|
||||
query, err := q.removeAllVarMatchers(query, vars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
varsData := map[string]any{}
|
||||
for k, v := range vars {
|
||||
varsData[k] = formatValueForProm(v.Value)
|
||||
@@ -83,7 +126,7 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
|
||||
}
|
||||
|
||||
tmpl := template.New("promql-query")
|
||||
tmpl, err := tmpl.Parse(query)
|
||||
tmpl, err = tmpl.Parse(query)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "error while replacing template variables")
|
||||
}
|
||||
|
||||
66
pkg/querier/promql_query_parser.go
Normal file
66
pkg/querier/promql_query_parser.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// allVarRemover is a visitor that removes label matchers referencing variables with __all__ value.
|
||||
// This must run before variable substitution so it can detect variable references in their original form.
|
||||
type allVarRemover struct {
|
||||
allVars map[string]bool // map of variable names that have __all__ value
|
||||
}
|
||||
|
||||
// Visit implements the parser.Visitor interface to traverse and modify the PromQL AST.
|
||||
func (v *allVarRemover) Visit(node parser.Node, path []parser.Node) (parser.Visitor, error) {
|
||||
if node == nil {
|
||||
return v, nil
|
||||
}
|
||||
switch n := node.(type) {
|
||||
case *parser.VectorSelector:
|
||||
// Remove matchers that reference variables with __all__ value
|
||||
var keptMatchers []*labels.Matcher
|
||||
for _, matcher := range n.LabelMatchers {
|
||||
if !v.shouldRemoveMatcher(matcher.Value) {
|
||||
keptMatchers = append(keptMatchers, matcher)
|
||||
}
|
||||
}
|
||||
// Update the label matchers
|
||||
n.LabelMatchers = keptMatchers
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// shouldRemoveMatcher checks if a matcher value contains a variable reference that has __all__ value.
|
||||
func (v *allVarRemover) shouldRemoveMatcher(value string) bool {
|
||||
|
||||
// Check for $var pattern
|
||||
if strings.Contains(value, "$") {
|
||||
keyValue := strings.TrimPrefix(value, "$")
|
||||
if _, ok := v.allVars[keyValue]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for {{var}} pattern
|
||||
if strings.Contains(value, "{{") {
|
||||
keyValue := strings.TrimPrefix(value, "{{")
|
||||
keyValue = strings.TrimSuffix(keyValue, "}}")
|
||||
if _, ok := v.allVars[keyValue]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for [[var]] pattern
|
||||
if strings.Contains(value, "[[") {
|
||||
keyValue := strings.TrimPrefix(value, "[[")
|
||||
keyValue = strings.TrimSuffix(keyValue, "]]")
|
||||
if _, ok := v.allVars[keyValue]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
85
pkg/querier/promql_query_parser_test.go
Normal file
85
pkg/querier/promql_query_parser_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShouldRemoveMatcher(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
allVars map[string]bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "$var pattern match",
|
||||
value: "$host.name",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "{{var}} pattern match",
|
||||
value: "{{host.name}}",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "[[var]] pattern match",
|
||||
value: "[[host.name]]",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "variable not in allVars",
|
||||
value: "$other.var",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no variable pattern in value",
|
||||
value: "host.name",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "variable in middle of string (not at start, won't match)",
|
||||
value: "prefix$host.namesuffix",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: false, // TrimPrefix only works if $ is at the start
|
||||
},
|
||||
{
|
||||
name: "empty allVars",
|
||||
value: "$host.name",
|
||||
allVars: map[string]bool{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "incomplete {{var}} pattern (missing closing, still matches)",
|
||||
value: "{{host.name",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: true, // TrimPrefix removes {{, TrimSuffix does nothing, checks "host.name"
|
||||
},
|
||||
{
|
||||
name: "mixed patterns (only first pattern at start matches)",
|
||||
value: "$host.name{{env}}",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: false, // TrimPrefix removes $, checks "host.name{{env}}" which is not in allVars
|
||||
},
|
||||
{
|
||||
name: "partial match should not match for {{var}}",
|
||||
value: "{{host.name.suffix}}",
|
||||
allVars: map[string]bool{"host.name": true},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
remover := &allVarRemover{allVars: tt.allVars}
|
||||
result := remover.shouldRemoveMatcher(tt.value)
|
||||
assert.Equal(t, tt.expected, result, "shouldRemoveMatcher(%q) with allVars=%v", tt.value, tt.allVars)
|
||||
})
|
||||
}
|
||||
}
|
||||
174
pkg/querier/promql_query_test.go
Normal file
174
pkg/querier/promql_query_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRemoveAllVarMatchers(t *testing.T) {
|
||||
logger := slog.Default()
|
||||
q := &promqlQuery{logger: logger}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
vars map[string]qbv5.VariableItem
|
||||
expected string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "remove $var pattern with __all__",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "remove {{var}} pattern with __all__",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"{{host.name}}"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "remove [[var]] pattern with __all__",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"[[host.name]]"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple variables, one with __all__",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name", "env"="$env"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
"env": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "production",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time",env="$env"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "no __all__ variables, query unchanged",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "host1",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "non-dynamic variable type, not removed",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.QueryVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid PromQL query",
|
||||
query: `invalid promql query syntax {`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid PromQL query with mismatched brackets",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty vars map",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{},
|
||||
expected: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple matchers with __all__ variable",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name", "env"="$env", "region"=~"$region"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
"env": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
"region": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: "us-east",
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time",region=~"$region"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "__all__ value not string type",
|
||||
query: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
vars: map[string]qbv5.VariableItem{
|
||||
"host.name": {
|
||||
Type: qbv5.DynamicVariableType,
|
||||
Value: 123, // Not a string
|
||||
},
|
||||
},
|
||||
expected: `sum(rate({__name__="system.cpu.time", "host.name"=~"$host.name"}[5m]))`,
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := q.removeAllVarMatchers(tt.query, tt.vars)
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result, "removeAllVarMatchers(%q) with vars=%v", tt.query, tt.vars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package agentConf
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -25,6 +24,6 @@ type AgentFeature interface {
|
||||
// TODO(Raj): maybe refactor agentConf further and clean this up
|
||||
serializedSettingsUsed string,
|
||||
|
||||
apiErr *model.ApiError,
|
||||
err error,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,16 +7,26 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var (
|
||||
CodeConfigVersionNotFound = errors.MustNewCode("config_version_not_found")
|
||||
CodeElementTypeRequired = errors.MustNewCode("element_type_required")
|
||||
CodeConfigElementsRequired = errors.MustNewCode("config_elements_required")
|
||||
CodeConfigVersionInsertFailed = errors.MustNewCode("config_version_insert_failed")
|
||||
CodeConfigElementInsertFailed = errors.MustNewCode("config_element_insert_failed")
|
||||
CodeConfigDeployStatusUpdateFailed = errors.MustNewCode("config_deploy_status_update_failed")
|
||||
CodeConfigHistoryGetFailed = errors.MustNewCode("config_history_get_failed")
|
||||
)
|
||||
|
||||
// Repo handles DDL and DML ops on ingestion rules
|
||||
type Repo struct {
|
||||
store sqlstore.SQLStore
|
||||
@@ -24,7 +34,7 @@ type Repo struct {
|
||||
|
||||
func (r *Repo) GetConfigHistory(
|
||||
ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType, limit int,
|
||||
) ([]opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) ([]opamptypes.AgentConfigVersion, error) {
|
||||
var c []opamptypes.AgentConfigVersion
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&c).
|
||||
@@ -39,7 +49,7 @@ func (r *Repo) GetConfigHistory(
|
||||
Scan(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, model.InternalError(err)
|
||||
return nil, errors.WrapInternalf(err, CodeConfigHistoryGetFailed, "failed to get config history")
|
||||
}
|
||||
|
||||
incompleteStatuses := []opamptypes.DeployStatus{opamptypes.DeployInitiated, opamptypes.Deploying}
|
||||
@@ -54,7 +64,7 @@ func (r *Repo) GetConfigHistory(
|
||||
|
||||
func (r *Repo) GetConfigVersion(
|
||||
ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType, v int,
|
||||
) (*opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) (*opamptypes.AgentConfigVersion, error) {
|
||||
var c opamptypes.AgentConfigVersion
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&c).
|
||||
@@ -69,9 +79,9 @@ func (r *Repo) GetConfigVersion(
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, model.NotFoundError(err)
|
||||
return nil, errors.WrapNotFoundf(err, CodeConfigVersionNotFound, "config version not found")
|
||||
}
|
||||
return nil, model.InternalError(err)
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to get config version")
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
@@ -79,7 +89,7 @@ func (r *Repo) GetConfigVersion(
|
||||
|
||||
func (r *Repo) GetLatestVersion(
|
||||
ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType,
|
||||
) (*opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) (*opamptypes.AgentConfigVersion, error) {
|
||||
var c opamptypes.AgentConfigVersion
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&c).
|
||||
@@ -93,9 +103,9 @@ func (r *Repo) GetLatestVersion(
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, model.NotFoundError(err)
|
||||
return nil, errors.WrapNotFoundf(err, CodeConfigVersionNotFound, "config latest version not found")
|
||||
}
|
||||
return nil, model.InternalError(err)
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to get latest config version")
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
@@ -103,18 +113,16 @@ func (r *Repo) GetLatestVersion(
|
||||
|
||||
func (r *Repo) insertConfig(
|
||||
ctx context.Context, orgId valuer.UUID, userId valuer.UUID, c *opamptypes.AgentConfigVersion, elements []string,
|
||||
) (fnerr *model.ApiError) {
|
||||
) error {
|
||||
|
||||
if c.ElementType.StringValue() == "" {
|
||||
return model.BadRequest(fmt.Errorf(
|
||||
"element type is required for creating agent config version",
|
||||
))
|
||||
return errors.NewInvalidInputf(CodeElementTypeRequired, "element type is required for creating agent config version")
|
||||
}
|
||||
|
||||
// allowing empty elements for logs - use case is deleting all pipelines
|
||||
if len(elements) == 0 && c.ElementType != opamptypes.ElementTypeLogPipelines {
|
||||
zap.L().Error("insert config called with no elements ", zap.String("ElementType", c.ElementType.StringValue()))
|
||||
return model.BadRequest(fmt.Errorf("config must have atleast one element"))
|
||||
return errors.NewInvalidInputf(CodeConfigElementsRequired, "config must have atleast one element")
|
||||
}
|
||||
|
||||
if c.Version != 0 {
|
||||
@@ -122,15 +130,13 @@ func (r *Repo) insertConfig(
|
||||
// in a monotonically increasing order starting with 1. hence, we reject insert
|
||||
// requests with version anything other than 0. here, 0 indicates un-assigned
|
||||
zap.L().Error("invalid version assignment while inserting agent config", zap.Int("version", c.Version), zap.String("ElementType", c.ElementType.StringValue()))
|
||||
return model.BadRequest(fmt.Errorf(
|
||||
"user defined versions are not supported in the agent config",
|
||||
))
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "user defined versions are not supported in the agent config")
|
||||
}
|
||||
|
||||
configVersion, err := r.GetLatestVersion(ctx, orgId, c.ElementType)
|
||||
if err != nil && err.Type() != model.ErrorNotFound {
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
zap.L().Error("failed to fetch latest config version", zap.Error(err))
|
||||
return model.InternalError(fmt.Errorf("failed to fetch latest config version"))
|
||||
return err
|
||||
}
|
||||
|
||||
if configVersion != nil {
|
||||
@@ -141,7 +147,7 @@ func (r *Repo) insertConfig(
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if fnerr != nil {
|
||||
if err != nil {
|
||||
// remove all the damage (invalid rows from db)
|
||||
r.store.BunDB().NewDelete().Model(new(opamptypes.AgentConfigVersion)).Where("id = ?", c.ID).Where("org_id = ?", orgId).Exec(ctx)
|
||||
r.store.BunDB().NewDelete().Model(new(opamptypes.AgentConfigElement)).Where("version_id = ?", c.ID).Exec(ctx)
|
||||
@@ -153,10 +159,9 @@ func (r *Repo) insertConfig(
|
||||
NewInsert().
|
||||
Model(c).
|
||||
Exec(ctx)
|
||||
|
||||
if dbErr != nil {
|
||||
zap.L().Error("error in inserting config version: ", zap.Error(dbErr))
|
||||
return model.InternalError(errors.Wrap(dbErr, "failed to insert ingestion rule"))
|
||||
return errors.WrapInternalf(dbErr, CodeConfigVersionInsertFailed, "failed to insert config version")
|
||||
}
|
||||
|
||||
for _, e := range elements {
|
||||
@@ -172,7 +177,7 @@ func (r *Repo) insertConfig(
|
||||
}
|
||||
_, dbErr = r.store.BunDB().NewInsert().Model(agentConfigElement).Exec(ctx)
|
||||
if dbErr != nil {
|
||||
return model.InternalError(dbErr)
|
||||
return errors.WrapInternalf(dbErr, CodeConfigElementInsertFailed, "failed to insert config element")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +219,7 @@ func (r *Repo) updateDeployStatus(ctx context.Context,
|
||||
|
||||
func (r *Repo) updateDeployStatusByHash(
|
||||
ctx context.Context, orgId valuer.UUID, confighash string, status string, result string,
|
||||
) *model.ApiError {
|
||||
|
||||
) error {
|
||||
_, err := r.store.BunDB().NewUpdate().
|
||||
Model(new(opamptypes.AgentConfigVersion)).
|
||||
Set("deploy_status = ?", status).
|
||||
@@ -225,7 +229,7 @@ func (r *Repo) updateDeployStatusByHash(
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to update deploy status", zap.Error(err))
|
||||
return model.InternalError(errors.Wrap(err, "failed to update deploy status"))
|
||||
return errors.WrapInternalf(err, CodeConfigDeployStatusUpdateFailed, "failed to update deploy status")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
filterprocessor "github.com/SigNoz/signoz/pkg/query-service/app/opamp/otelconfig/filterprocessor"
|
||||
tsp "github.com/SigNoz/signoz/pkg/query-service/app/opamp/otelconfig/tailsampler"
|
||||
@@ -16,13 +17,16 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var m *Manager
|
||||
|
||||
var (
|
||||
CodeConfigVersionNoConfig = errors.MustNewCode("config_version_no_config")
|
||||
)
|
||||
|
||||
func init() {
|
||||
m = &Manager{}
|
||||
}
|
||||
@@ -103,16 +107,14 @@ func (m *Manager) RecommendAgentConfig(orgId valuer.UUID, currentConfYaml []byte
|
||||
|
||||
for _, feature := range m.agentFeatures {
|
||||
featureType := opamptypes.NewElementType(string(feature.AgentFeatureType()))
|
||||
latestConfig, apiErr := GetLatestVersion(context.Background(), orgId, featureType)
|
||||
if apiErr != nil && apiErr.Type() != model.ErrorNotFound {
|
||||
return nil, "", errors.Wrap(apiErr.ToError(), "failed to get latest agent config version")
|
||||
latestConfig, err := GetLatestVersion(context.Background(), orgId, featureType)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, serializedSettingsUsed, apiErr := feature.RecommendAgentConfig(orgId, recommendation, latestConfig)
|
||||
if apiErr != nil {
|
||||
return nil, "", errors.Wrap(apiErr.ToError(), fmt.Sprintf(
|
||||
"failed to generate agent config recommendation for %s", featureType,
|
||||
))
|
||||
updatedConf, serializedSettingsUsed, err := feature.RecommendAgentConfig(orgId, recommendation, latestConfig)
|
||||
if err != nil {
|
||||
return nil, "", errors.WithAdditionalf(err, "agent config recommendation for %s failed", featureType)
|
||||
}
|
||||
recommendation = updatedConf
|
||||
|
||||
@@ -178,26 +180,26 @@ func (m *Manager) ReportConfigDeploymentStatus(
|
||||
|
||||
func GetLatestVersion(
|
||||
ctx context.Context, orgId valuer.UUID, elementType opamptypes.ElementType,
|
||||
) (*opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) (*opamptypes.AgentConfigVersion, error) {
|
||||
return m.GetLatestVersion(ctx, orgId, elementType)
|
||||
}
|
||||
|
||||
func GetConfigVersion(
|
||||
ctx context.Context, orgId valuer.UUID, elementType opamptypes.ElementType, version int,
|
||||
) (*opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) (*opamptypes.AgentConfigVersion, error) {
|
||||
return m.GetConfigVersion(ctx, orgId, elementType, version)
|
||||
}
|
||||
|
||||
func GetConfigHistory(
|
||||
ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType, limit int,
|
||||
) ([]opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) ([]opamptypes.AgentConfigVersion, error) {
|
||||
return m.GetConfigHistory(ctx, orgId, typ, limit)
|
||||
}
|
||||
|
||||
// StartNewVersion launches a new config version for given set of elements
|
||||
func StartNewVersion(
|
||||
ctx context.Context, orgId valuer.UUID, userId valuer.UUID, eleType opamptypes.ElementType, elementIds []string,
|
||||
) (*opamptypes.AgentConfigVersion, *model.ApiError) {
|
||||
) (*opamptypes.AgentConfigVersion, error) {
|
||||
|
||||
// create a new version
|
||||
cfg := opamptypes.NewAgentConfigVersion(orgId, userId, eleType)
|
||||
@@ -217,17 +219,16 @@ func NotifyConfigUpdate(ctx context.Context) {
|
||||
m.notifyConfigUpdateSubscribers()
|
||||
}
|
||||
|
||||
func Redeploy(ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType, version int) *model.ApiError {
|
||||
|
||||
func Redeploy(ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType, version int) error {
|
||||
configVersion, err := GetConfigVersion(ctx, orgId, typ, version)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to fetch config version during redeploy", zap.Error(err))
|
||||
return model.WrapApiError(err, "failed to fetch details of the config version")
|
||||
return err
|
||||
}
|
||||
|
||||
if configVersion == nil || (configVersion != nil && configVersion.Config == "") {
|
||||
zap.L().Debug("config version has no conf yaml", zap.Any("configVersion", configVersion))
|
||||
return model.BadRequest(fmt.Errorf("the config version can not be redeployed"))
|
||||
return errors.NewInvalidInputf(CodeConfigVersionNoConfig, "the config version can not be redeployed")
|
||||
}
|
||||
switch typ {
|
||||
case opamptypes.ElementTypeSamplingRules:
|
||||
@@ -246,7 +247,7 @@ func Redeploy(ctx context.Context, orgId valuer.UUID, typ opamptypes.ElementType
|
||||
configHash, err := opamp.UpsertControlProcessors(ctx, "traces", processorConf, m.OnConfigUpdate)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to call agent config update for trace processor", zap.Error(err))
|
||||
return model.InternalError(fmt.Errorf("failed to deploy the config"))
|
||||
return errors.WithAdditionalf(err, "failed to deploy the config")
|
||||
}
|
||||
|
||||
m.updateDeployStatus(ctx, orgId, opamptypes.ElementTypeSamplingRules, version, opamptypes.DeployInitiated.StringValue(), "Deployment started", configHash, configVersion.Config)
|
||||
|
||||
@@ -116,7 +116,7 @@ func (c *Controller) GenerateConnectionUrl(ctx context.Context, orgId string, cl
|
||||
return nil, model.WrapApiError(apiErr, "couldn't upsert cloud account")
|
||||
}
|
||||
|
||||
agentVersion := "v0.0.6"
|
||||
agentVersion := "v0.0.7"
|
||||
if req.AgentConfig.Version != "" {
|
||||
agentVersion = req.AgentConfig.Version
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
|
||||
|
||||
"io"
|
||||
@@ -1791,7 +1791,7 @@ func (aH *APIHandler) GetWaterfallSpansForTraceWithMetadata(w http.ResponseWrite
|
||||
}
|
||||
traceID := mux.Vars(r)["traceId"]
|
||||
if traceID == "" {
|
||||
RespondError(w, model.BadRequest(errors.New("traceID is required")), nil)
|
||||
render.Error(w, errors.NewInvalidInputf(errors.CodeInvalidInput, "traceID is required"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1825,7 +1825,7 @@ func (aH *APIHandler) GetFlamegraphSpansForTrace(w http.ResponseWriter, r *http.
|
||||
|
||||
traceID := mux.Vars(r)["traceId"]
|
||||
if traceID == "" {
|
||||
RespondError(w, model.BadRequest(errors.New("traceID is required")), nil)
|
||||
render.Error(w, errors.NewInvalidInputf(errors.CodeInvalidInput, "traceID is required"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1926,9 +1926,9 @@ func (aH *APIHandler) setTTL(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
RespondError(w, &model.ApiError{Err: errors.New("failed to get org id from context"), Typ: model.ErrorInternal}, nil)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, errors.NewInternalf(errors.CodeInternal, "failed to get org id from context"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1995,17 +1995,15 @@ func (aH *APIHandler) getTTL(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
RespondError(w, &model.ApiError{Err: errors.New("failed to get org id from context"), Typ: model.ErrorInternal}, nil)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetTTL(r.Context(), claims.OrgID, ttlParams)
|
||||
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
aH.WriteJSON(w, r, result)
|
||||
}
|
||||
|
||||
@@ -2070,7 +2068,7 @@ func (aH *APIHandler) getHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
|
||||
if aH.SetupCompleted {
|
||||
RespondError(w, &model.ApiError{Err: errors.New("self-registration is disabled"), Typ: model.ErrorBadData}, nil)
|
||||
render.Error(w, errors.NewInvalidInputf(errors.CodeInvalidInput, "self-registration is disabled"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3453,7 +3451,7 @@ func (aH *APIHandler) InstallIntegration(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
RespondError(w, model.UnauthorizedError(errors.New("unauthorized")), nil)
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4064,7 +4062,7 @@ func (aH *APIHandler) logAggregate(w http.ResponseWriter, r *http.Request) {
|
||||
aH.WriteJSON(w, r, model.GetLogsAggregatesResponse{})
|
||||
}
|
||||
|
||||
func parseAgentConfigVersion(r *http.Request) (int, *model.ApiError) {
|
||||
func parseAgentConfigVersion(r *http.Request) (int, error) {
|
||||
versionString := mux.Vars(r)["version"]
|
||||
|
||||
if versionString == "latest" {
|
||||
@@ -4074,11 +4072,11 @@ func parseAgentConfigVersion(r *http.Request) (int, *model.ApiError) {
|
||||
version64, err := strconv.ParseInt(versionString, 0, 8)
|
||||
|
||||
if err != nil {
|
||||
return 0, model.BadRequestStr("invalid version number")
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid version number")
|
||||
}
|
||||
|
||||
if version64 <= 0 {
|
||||
return 0, model.BadRequestStr("invalid version number")
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid version number")
|
||||
}
|
||||
|
||||
return int(version64), nil
|
||||
@@ -4088,16 +4086,13 @@ func (aH *APIHandler) PreviewLogsPipelinesHandler(w http.ResponseWriter, r *http
|
||||
req := logparsingpipeline.PipelinesPreviewRequest{}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
RespondError(w, model.BadRequest(err), nil)
|
||||
render.Error(w, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to decode request body"))
|
||||
return
|
||||
}
|
||||
|
||||
resultLogs, apiErr := aH.LogsParsingPipelineController.PreviewLogsPipelines(
|
||||
r.Context(), &req,
|
||||
)
|
||||
|
||||
if apiErr != nil {
|
||||
RespondError(w, apiErr, nil)
|
||||
resultLogs, err := aH.LogsParsingPipelineController.PreviewLogsPipelines(r.Context(), &req)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4105,9 +4100,9 @@ func (aH *APIHandler) PreviewLogsPipelinesHandler(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
func (aH *APIHandler) ListLogsPipelinesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4119,35 +4114,33 @@ func (aH *APIHandler) ListLogsPipelinesHandler(w http.ResponseWriter, r *http.Re
|
||||
|
||||
version, err := parseAgentConfigVersion(r)
|
||||
if err != nil {
|
||||
RespondError(w, model.WrapApiError(err, "Failed to parse agent config version"), nil)
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var payload *logparsingpipeline.PipelinesResponse
|
||||
var apierr *model.ApiError
|
||||
|
||||
if version != -1 {
|
||||
payload, apierr = aH.listLogsPipelinesByVersion(context.Background(), orgID, version)
|
||||
payload, err = aH.listLogsPipelinesByVersion(r.Context(), orgID, version)
|
||||
} else {
|
||||
payload, apierr = aH.listLogsPipelines(context.Background(), orgID)
|
||||
payload, err = aH.listLogsPipelines(r.Context(), orgID)
|
||||
}
|
||||
|
||||
if apierr != nil {
|
||||
RespondError(w, apierr, payload)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, payload)
|
||||
}
|
||||
|
||||
// listLogsPipelines lists logs piplines for latest version
|
||||
func (aH *APIHandler) listLogsPipelines(ctx context.Context, orgID valuer.UUID) (
|
||||
*logparsingpipeline.PipelinesResponse, *model.ApiError,
|
||||
*logparsingpipeline.PipelinesResponse, error,
|
||||
) {
|
||||
// get lateset agent config
|
||||
latestVersion := -1
|
||||
lastestConfig, err := agentConf.GetLatestVersion(ctx, orgID, opamptypes.ElementTypeLogPipelines)
|
||||
if err != nil && err.Type() != model.ErrorNotFound {
|
||||
return nil, model.WrapApiError(err, "failed to get latest agent config version")
|
||||
if err != nil && !errorsV2.Ast(err, errorsV2.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if lastestConfig != nil {
|
||||
@@ -4156,14 +4149,14 @@ func (aH *APIHandler) listLogsPipelines(ctx context.Context, orgID valuer.UUID)
|
||||
|
||||
payload, err := aH.LogsParsingPipelineController.GetPipelinesByVersion(ctx, orgID, latestVersion)
|
||||
if err != nil {
|
||||
return nil, model.WrapApiError(err, "failed to get pipelines")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// todo(Nitya): make a new API for history pagination
|
||||
limit := 10
|
||||
history, err := agentConf.GetConfigHistory(ctx, orgID, opamptypes.ElementTypeLogPipelines, limit)
|
||||
if err != nil {
|
||||
return nil, model.WrapApiError(err, "failed to get config history")
|
||||
return nil, err
|
||||
}
|
||||
payload.History = history
|
||||
return payload, nil
|
||||
@@ -4171,18 +4164,18 @@ func (aH *APIHandler) listLogsPipelines(ctx context.Context, orgID valuer.UUID)
|
||||
|
||||
// listLogsPipelinesByVersion lists pipelines along with config version history
|
||||
func (aH *APIHandler) listLogsPipelinesByVersion(ctx context.Context, orgID valuer.UUID, version int) (
|
||||
*logparsingpipeline.PipelinesResponse, *model.ApiError,
|
||||
*logparsingpipeline.PipelinesResponse, error,
|
||||
) {
|
||||
payload, err := aH.LogsParsingPipelineController.GetPipelinesByVersion(ctx, orgID, version)
|
||||
if err != nil {
|
||||
return nil, model.WrapApiError(err, "failed to get pipelines by version")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// todo(Nitya): make a new API for history pagination
|
||||
limit := 10
|
||||
history, err := agentConf.GetConfigHistory(ctx, orgID, opamptypes.ElementTypeLogPipelines, limit)
|
||||
if err != nil {
|
||||
return nil, model.WrapApiError(err, "failed to retrieve agent config history")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload.History = history
|
||||
@@ -4218,14 +4211,14 @@ func (aH *APIHandler) CreateLogsPipeline(w http.ResponseWriter, r *http.Request)
|
||||
createPipeline := func(
|
||||
ctx context.Context,
|
||||
postable []pipelinetypes.PostablePipeline,
|
||||
) (*logparsingpipeline.PipelinesResponse, *model.ApiError) {
|
||||
) (*logparsingpipeline.PipelinesResponse, error) {
|
||||
if len(postable) == 0 {
|
||||
zap.L().Warn("found no pipelines in the http request, this will delete all the pipelines")
|
||||
}
|
||||
|
||||
validationErr := aH.LogsParsingPipelineController.ValidatePipelines(ctx, postable)
|
||||
if validationErr != nil {
|
||||
return nil, validationErr
|
||||
err := aH.LogsParsingPipelineController.ValidatePipelines(ctx, postable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return aH.LogsParsingPipelineController.ApplyPipelines(ctx, orgID, userID, postable)
|
||||
@@ -4233,7 +4226,7 @@ func (aH *APIHandler) CreateLogsPipeline(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
res, err := createPipeline(r.Context(), req.Pipelines)
|
||||
if err != nil {
|
||||
RespondError(w, err, nil)
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ func (c *Controller) Uninstall(ctx context.Context, orgId string, req *Uninstall
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) GetPipelinesForInstalledIntegrations(ctx context.Context, orgId string) ([]pipelinetypes.GettablePipeline, *model.ApiError) {
|
||||
func (c *Controller) GetPipelinesForInstalledIntegrations(ctx context.Context, orgId string) ([]pipelinetypes.GettablePipeline, error) {
|
||||
return c.mgr.GetPipelinesForInstalledIntegrations(ctx, orgId)
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ func (m *Manager) UninstallIntegration(
|
||||
func (m *Manager) GetPipelinesForInstalledIntegrations(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
) ([]pipelinetypes.GettablePipeline, *model.ApiError) {
|
||||
) ([]pipelinetypes.GettablePipeline, error) {
|
||||
installedIntegrations, apiErr := m.getInstalledIntegrations(ctx, orgId)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
|
||||
@@ -8,15 +8,23 @@ import (
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
coreModel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var lockLogsPipelineSpec sync.RWMutex
|
||||
|
||||
var (
|
||||
CodeCollectorConfigUnmarshalFailed = errors.MustNewCode("collector_config_unmarshal_failed")
|
||||
CodeCollectorConfigMarshalFailed = errors.MustNewCode("collector_config_marshal_failed")
|
||||
CodeCollectorConfigServiceNotFound = errors.MustNewCode("collector_config_service_not_found")
|
||||
CodeCollectorConfigServiceMarshalFailed = errors.MustNewCode("collector_config_service_marshal_failed")
|
||||
CodeCollectorConfigServiceUnmarshalFailed = errors.MustNewCode("collector_config_service_unmarshal_failed")
|
||||
CodeCollectorConfigLogsPipelineNotFound = errors.MustNewCode("collector_config_logs_pipeline_not_found")
|
||||
)
|
||||
|
||||
// check if the processors already exist
|
||||
// if yes then update the processor.
|
||||
// if something doesn't exists then remove it.
|
||||
@@ -57,15 +65,15 @@ type otelPipeline struct {
|
||||
|
||||
func getOtelPipelineFromConfig(config map[string]interface{}) (*otelPipeline, error) {
|
||||
if _, ok := config["service"]; !ok {
|
||||
return nil, fmt.Errorf("service not found in OTEL config")
|
||||
return nil, errors.NewInvalidInputf(CodeCollectorConfigServiceNotFound, "service not found in OTEL config")
|
||||
}
|
||||
b, err := json.Marshal(config["service"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.WrapInternalf(err, CodeCollectorConfigServiceMarshalFailed, "could not marshal OTEL config")
|
||||
}
|
||||
p := otelPipeline{}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return nil, err
|
||||
return nil, errors.WrapInternalf(err, CodeCollectorConfigServiceUnmarshalFailed, "could not unmarshal OTEL config")
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
@@ -163,21 +171,16 @@ func checkDuplicateString(pipeline []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func GenerateCollectorConfigWithPipelines(
|
||||
config []byte,
|
||||
pipelines []pipelinetypes.GettablePipeline,
|
||||
) ([]byte, *coreModel.ApiError) {
|
||||
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
|
||||
var collectorConf map[string]interface{}
|
||||
err := yaml.Unmarshal([]byte(config), &collectorConf)
|
||||
if err != nil {
|
||||
return nil, coreModel.BadRequest(err)
|
||||
return nil, errors.WrapInvalidInputf(err, CodeCollectorConfigUnmarshalFailed, "could not unmarshal collector config")
|
||||
}
|
||||
|
||||
signozPipelineProcessors, signozPipelineProcNames, err := PreparePipelineProcessor(pipelines)
|
||||
if err != nil {
|
||||
return nil, coreModel.BadRequest(errors.Wrap(
|
||||
err, "could not prepare otel collector processors for log pipelines",
|
||||
))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Escape any `$`s as `$$$` in config generated for pipelines, to ensure any occurrences
|
||||
@@ -186,9 +189,7 @@ func GenerateCollectorConfigWithPipelines(
|
||||
procConf := signozPipelineProcessors[procName]
|
||||
serializedProcConf, err := yaml.Marshal(procConf)
|
||||
if err != nil {
|
||||
return nil, coreModel.InternalError(fmt.Errorf(
|
||||
"could not marshal processor config for %s: %w", procName, err,
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, CodeCollectorConfigMarshalFailed, "could not marshal processor config for %s", procName)
|
||||
}
|
||||
escapedSerializedConf := strings.ReplaceAll(
|
||||
string(serializedProcConf), "$", "$$",
|
||||
@@ -197,9 +198,7 @@ func GenerateCollectorConfigWithPipelines(
|
||||
var escapedConf map[string]interface{}
|
||||
err = yaml.Unmarshal([]byte(escapedSerializedConf), &escapedConf)
|
||||
if err != nil {
|
||||
return nil, coreModel.InternalError(fmt.Errorf(
|
||||
"could not unmarshal dollar escaped processor config for %s: %w", procName, err,
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, CodeCollectorConfigUnmarshalFailed, "could not unmarshal dollar escaped processor config for %s", procName)
|
||||
}
|
||||
|
||||
signozPipelineProcessors[procName] = escapedConf
|
||||
@@ -211,12 +210,10 @@ func GenerateCollectorConfigWithPipelines(
|
||||
// build the new processor list in service.pipelines.logs
|
||||
p, err := getOtelPipelineFromConfig(collectorConf)
|
||||
if err != nil {
|
||||
return nil, coreModel.BadRequest(err)
|
||||
return nil, err
|
||||
}
|
||||
if p.Pipelines.Logs == nil {
|
||||
return nil, coreModel.InternalError(fmt.Errorf(
|
||||
"logs pipeline doesn't exist",
|
||||
))
|
||||
return nil, errors.NewInternalf(CodeCollectorConfigLogsPipelineNotFound, "logs pipeline doesn't exist")
|
||||
}
|
||||
|
||||
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames)
|
||||
@@ -227,7 +224,7 @@ func GenerateCollectorConfigWithPipelines(
|
||||
|
||||
updatedConf, err := yaml.Marshal(collectorConf)
|
||||
if err != nil {
|
||||
return nil, coreModel.BadRequest(err)
|
||||
return nil, errors.WrapInternalf(err, CodeCollectorConfigMarshalFailed, "could not marshal collector config")
|
||||
}
|
||||
|
||||
return updatedConf, nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
@@ -17,20 +18,23 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
CodeRawPipelinesMarshalFailed = errors.MustNewCode("raw_pipelines_marshal_failed")
|
||||
)
|
||||
|
||||
// Controller takes care of deployment cycle of log parsing pipelines.
|
||||
type LogParsingPipelineController struct {
|
||||
Repo
|
||||
|
||||
GetIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, *model.ApiError)
|
||||
GetIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, error)
|
||||
}
|
||||
|
||||
func NewLogParsingPipelinesController(
|
||||
sqlStore sqlstore.SQLStore,
|
||||
getIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, *model.ApiError),
|
||||
getIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, error),
|
||||
) (*LogParsingPipelineController, error) {
|
||||
repo := NewRepo(sqlStore)
|
||||
return &LogParsingPipelineController{
|
||||
@@ -53,7 +57,7 @@ func (ic *LogParsingPipelineController) ApplyPipelines(
|
||||
orgID valuer.UUID,
|
||||
userID valuer.UUID,
|
||||
postable []pipelinetypes.PostablePipeline,
|
||||
) (*PipelinesResponse, *model.ApiError) {
|
||||
) (*PipelinesResponse, error) {
|
||||
var pipelines []pipelinetypes.GettablePipeline
|
||||
|
||||
// scan through postable pipelines, to select the existing pipelines or insert missing ones
|
||||
@@ -68,9 +72,9 @@ func (ic *LogParsingPipelineController) ApplyPipelines(
|
||||
// the same pipeline id.
|
||||
r.ID = uuid.NewString()
|
||||
r.OrderID = idx + 1
|
||||
pipeline, apiErr := ic.insertPipeline(ctx, orgID, &r)
|
||||
if apiErr != nil {
|
||||
return nil, model.WrapApiError(apiErr, "failed to insert pipeline")
|
||||
pipeline, err := ic.insertPipeline(ctx, orgID, &r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pipelines = append(pipelines, *pipeline)
|
||||
|
||||
@@ -90,13 +94,12 @@ func (ic *LogParsingPipelineController) ApplyPipelines(
|
||||
return ic.GetPipelinesByVersion(ctx, orgID, cfg.Version)
|
||||
}
|
||||
|
||||
func (ic *LogParsingPipelineController) ValidatePipelines(
|
||||
ctx context.Context,
|
||||
func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
|
||||
postedPipelines []pipelinetypes.PostablePipeline,
|
||||
) *model.ApiError {
|
||||
) error {
|
||||
for _, p := range postedPipelines {
|
||||
if err := p.IsValid(); err != nil {
|
||||
return model.BadRequestStr(err.Error())
|
||||
return errors.WithAdditionalf(err, "invalid pipeline: %s", p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,39 +124,29 @@ func (ic *LogParsingPipelineController) ValidatePipelines(
|
||||
}
|
||||
|
||||
sampleLogs := []model.SignozLog{{Body: ""}}
|
||||
_, _, simulationErr := SimulatePipelinesProcessing(
|
||||
ctx, gettablePipelines, sampleLogs,
|
||||
)
|
||||
if simulationErr != nil {
|
||||
return model.BadRequest(fmt.Errorf(
|
||||
"invalid pipelines config: %w", simulationErr.ToError(),
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
_, _, err := SimulatePipelinesProcessing(ctx, gettablePipelines, sampleLogs)
|
||||
return err
|
||||
}
|
||||
|
||||
// Returns effective list of pipelines including user created
|
||||
// pipelines and pipelines for installed integrations
|
||||
func (ic *LogParsingPipelineController) getEffectivePipelinesByVersion(
|
||||
ctx context.Context, orgID valuer.UUID, version int,
|
||||
) ([]pipelinetypes.GettablePipeline, *model.ApiError) {
|
||||
) ([]pipelinetypes.GettablePipeline, error) {
|
||||
|
||||
result := []pipelinetypes.GettablePipeline{}
|
||||
if version >= 0 {
|
||||
savedPipelines, errors := ic.getPipelinesByVersion(ctx, orgID.String(), version)
|
||||
if errors != nil {
|
||||
zap.L().Error("failed to get pipelines for version", zap.Int("version", version), zap.Errors("errors", errors))
|
||||
return nil, model.InternalError(fmt.Errorf("failed to get pipelines for given version %v", errors))
|
||||
savedPipelines, err := ic.getPipelinesByVersion(ctx, orgID.String(), version)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get pipelines for version", zap.Int("version", version), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
result = savedPipelines
|
||||
}
|
||||
|
||||
integrationPipelines, apiErr := ic.GetIntegrationPipelines(ctx, orgID.String())
|
||||
if apiErr != nil {
|
||||
return nil, model.WrapApiError(
|
||||
apiErr, "could not get pipelines for installed integrations",
|
||||
)
|
||||
integrationPipelines, err := ic.GetIntegrationPipelines(ctx, orgID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter out any integration pipelines included in pipelines saved by user
|
||||
@@ -194,12 +187,11 @@ func (ic *LogParsingPipelineController) getEffectivePipelinesByVersion(
|
||||
// GetPipelinesByVersion responds with version info and associated pipelines
|
||||
func (ic *LogParsingPipelineController) GetPipelinesByVersion(
|
||||
ctx context.Context, orgId valuer.UUID, version int,
|
||||
) (*PipelinesResponse, *model.ApiError) {
|
||||
|
||||
pipelines, errors := ic.getEffectivePipelinesByVersion(ctx, orgId, version)
|
||||
if errors != nil {
|
||||
zap.L().Error("failed to get pipelines for version", zap.Int("version", version), zap.Error(errors))
|
||||
return nil, model.InternalError(fmt.Errorf("failed to get pipelines for given version %v", errors))
|
||||
) (*PipelinesResponse, error) {
|
||||
pipelines, err := ic.getEffectivePipelinesByVersion(ctx, orgId, version)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get pipelines for version", zap.Int("version", version), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var configVersion *opamptypes.AgentConfigVersion
|
||||
@@ -207,7 +199,7 @@ func (ic *LogParsingPipelineController) GetPipelinesByVersion(
|
||||
cv, err := agentConf.GetConfigVersion(ctx, orgId, opamptypes.ElementTypeLogPipelines, version)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get config for version", zap.Int("version", version), zap.Error(err))
|
||||
return nil, model.WrapApiError(err, "failed to get config for given version")
|
||||
return nil, err
|
||||
}
|
||||
configVersion = cv
|
||||
}
|
||||
@@ -231,11 +223,8 @@ type PipelinesPreviewResponse struct {
|
||||
func (ic *LogParsingPipelineController) PreviewLogsPipelines(
|
||||
ctx context.Context,
|
||||
request *PipelinesPreviewRequest,
|
||||
) (*PipelinesPreviewResponse, *model.ApiError) {
|
||||
result, collectorLogs, err := SimulatePipelinesProcessing(
|
||||
ctx, request.Pipelines, request.Logs,
|
||||
)
|
||||
|
||||
) (*PipelinesPreviewResponse, error) {
|
||||
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, request.Pipelines, request.Logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -256,33 +245,27 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
|
||||
orgId valuer.UUID,
|
||||
currentConfYaml []byte,
|
||||
configVersion *opamptypes.AgentConfigVersion,
|
||||
) (
|
||||
recommendedConfYaml []byte,
|
||||
serializedSettingsUsed string,
|
||||
apiErr *model.ApiError,
|
||||
) {
|
||||
) ([]byte, string, error) {
|
||||
pipelinesVersion := -1
|
||||
if configVersion != nil {
|
||||
pipelinesVersion = configVersion.Version
|
||||
}
|
||||
|
||||
pipelinesResp, apiErr := pc.GetPipelinesByVersion(
|
||||
pipelinesResp, err := pc.GetPipelinesByVersion(
|
||||
context.Background(), orgId, pipelinesVersion,
|
||||
)
|
||||
if apiErr != nil {
|
||||
return nil, "", apiErr
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, apiErr := GenerateCollectorConfigWithPipelines(
|
||||
currentConfYaml, pipelinesResp.Pipelines,
|
||||
)
|
||||
if apiErr != nil {
|
||||
return nil, "", model.WrapApiError(apiErr, "could not marshal yaml for updated conf")
|
||||
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, pipelinesResp.Pipelines)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
rawPipelineData, err := json.Marshal(pipelinesResp.Pipelines)
|
||||
if err != nil {
|
||||
return nil, "", model.BadRequest(errors.Wrap(err, "could not serialize pipelines to JSON"))
|
||||
return nil, "", errors.WrapInternalf(err, CodeRawPipelinesMarshalFailed, "could not serialize pipelines to JSON")
|
||||
}
|
||||
|
||||
return updatedConf, string(rawPipelineData), nil
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"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/pipelinetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -33,24 +33,18 @@ func NewRepo(sqlStore sqlstore.SQLStore) Repo {
|
||||
// insertPipeline stores a given postable pipeline to database
|
||||
func (r *Repo) insertPipeline(
|
||||
ctx context.Context, orgID valuer.UUID, postable *pipelinetypes.PostablePipeline,
|
||||
) (*pipelinetypes.GettablePipeline, *model.ApiError) {
|
||||
) (*pipelinetypes.GettablePipeline, error) {
|
||||
if err := postable.IsValid(); err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err,
|
||||
"pipeline is not valid",
|
||||
))
|
||||
return nil, errors.WithAdditionalf(err, "pipeline is not valid")
|
||||
}
|
||||
|
||||
rawConfig, err := json.Marshal(postable.Config)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err,
|
||||
"failed to unmarshal postable pipeline config",
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to unmarshal postable pipeline config")
|
||||
}
|
||||
filter, err := json.Marshal(postable.Filter)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err,
|
||||
"failed to marshal postable pipeline filter",
|
||||
))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to marshal postable pipeline filter")
|
||||
}
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -85,10 +79,9 @@ func (r *Repo) insertPipeline(
|
||||
_, err = r.sqlStore.BunDB().NewInsert().
|
||||
Model(&insertRow.StoreablePipeline).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
zap.L().Error("error in inserting pipeline data", zap.Error(err))
|
||||
return nil, model.InternalError(errors.Wrap(err, "failed to insert pipeline"))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to insert pipeline")
|
||||
}
|
||||
|
||||
return insertRow, nil
|
||||
@@ -97,8 +90,7 @@ func (r *Repo) insertPipeline(
|
||||
// getPipelinesByVersion returns pipelines associated with a given version
|
||||
func (r *Repo) getPipelinesByVersion(
|
||||
ctx context.Context, orgID string, version int,
|
||||
) ([]pipelinetypes.GettablePipeline, []error) {
|
||||
var errors []error
|
||||
) ([]pipelinetypes.GettablePipeline, error) {
|
||||
storablePipelines := []pipelinetypes.StoreablePipeline{}
|
||||
err := r.sqlStore.BunDB().NewSelect().
|
||||
Model(&storablePipelines).
|
||||
@@ -110,7 +102,7 @@ func (r *Repo) getPipelinesByVersion(
|
||||
Order("p.order_id ASC").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, []error{fmt.Errorf("failed to get pipelines from db: %v", err)}
|
||||
return nil, errors.WrapInternalf(err, CodePipelinesGetFailed, "failed to get pipelines from db")
|
||||
}
|
||||
|
||||
gettablePipelines := make([]pipelinetypes.GettablePipeline, len(storablePipelines))
|
||||
@@ -118,23 +110,24 @@ func (r *Repo) getPipelinesByVersion(
|
||||
return gettablePipelines, nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for i := range storablePipelines {
|
||||
gettablePipelines[i].StoreablePipeline = storablePipelines[i]
|
||||
if err := gettablePipelines[i].ParseRawConfig(); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if err := gettablePipelines[i].ParseFilter(); err != nil {
|
||||
errors = append(errors, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return gettablePipelines, errors
|
||||
return gettablePipelines, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// GetPipelines returns pipeline and errors (if any)
|
||||
func (r *Repo) GetPipeline(
|
||||
ctx context.Context, orgID string, id string,
|
||||
) (*pipelinetypes.GettablePipeline, *model.ApiError) {
|
||||
) (*pipelinetypes.GettablePipeline, error) {
|
||||
storablePipelines := []pipelinetypes.StoreablePipeline{}
|
||||
|
||||
err := r.sqlStore.BunDB().NewSelect().
|
||||
@@ -144,12 +137,12 @@ func (r *Repo) GetPipeline(
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get ingestion pipeline from db", zap.Error(err))
|
||||
return nil, model.InternalError(errors.Wrap(err, "failed to get ingestion pipeline from db"))
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to get ingestion pipeline from db")
|
||||
}
|
||||
|
||||
if len(storablePipelines) == 0 {
|
||||
zap.L().Warn("No row found for ingestion pipeline id", zap.String("id", id))
|
||||
return nil, model.NotFoundError(fmt.Errorf("no row found for ingestion pipeline id %v", id))
|
||||
return nil, errors.NewNotFoundf(errors.CodeNotFound, "no row found for ingestion pipeline id %v", id)
|
||||
}
|
||||
|
||||
if len(storablePipelines) == 1 {
|
||||
@@ -157,20 +150,16 @@ func (r *Repo) GetPipeline(
|
||||
gettablePipeline.StoreablePipeline = storablePipelines[0]
|
||||
if err := gettablePipeline.ParseRawConfig(); err != nil {
|
||||
zap.L().Error("invalid pipeline config found", zap.String("id", id), zap.Error(err))
|
||||
return nil, model.InternalError(
|
||||
errors.Wrap(err, "found an invalid pipeline config"),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if err := gettablePipeline.ParseFilter(); err != nil {
|
||||
zap.L().Error("invalid pipeline filter found", zap.String("id", id), zap.Error(err))
|
||||
return nil, model.InternalError(
|
||||
errors.Wrap(err, "found an invalid pipeline filter"),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
return &gettablePipeline, nil
|
||||
}
|
||||
|
||||
return nil, model.InternalError(fmt.Errorf("multiple pipelines with same id"))
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "multiple pipelines with same id")
|
||||
}
|
||||
|
||||
func (r *Repo) DeletePipeline(ctx context.Context, orgID string, id string) error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user