mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-30 17:10:26 +01:00
Compare commits
21 Commits
feat/v2-us
...
fix/array-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9299c8ab18 | ||
|
|
24749de269 | ||
|
|
39098ec3f4 | ||
|
|
fe554f5c94 | ||
|
|
8a60a041a6 | ||
|
|
541f19c34a | ||
|
|
010db03d6e | ||
|
|
5408acbd8c | ||
|
|
0de6c85f81 | ||
|
|
69ec24fa05 | ||
|
|
539d732b65 | ||
|
|
843d5fb199 | ||
|
|
fabdfb8cc1 | ||
|
|
f3e6892d5b | ||
|
|
23a4960e74 | ||
|
|
5d0c55d682 | ||
|
|
15704e0433 | ||
|
|
5db0501c02 | ||
|
|
73da474563 | ||
|
|
028c134ea9 | ||
|
|
31b61a89fd |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.117.0
|
||||
image: signoz/signoz:v0.117.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.117.0
|
||||
image: signoz/signoz:v0.117.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.117.0}
|
||||
image: signoz/signoz:${VERSION:-v0.117.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.117.0}
|
||||
image: signoz/signoz:${VERSION:-v0.117.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -116,7 +116,12 @@ describe.each([
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows changing export format', () => {
|
||||
@@ -146,6 +151,17 @@ describe.each([
|
||||
});
|
||||
|
||||
it('allows changing columns scope', () => {
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
|
||||
expect(screen.queryByRole('radio', { name: 'All' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: 'Selected' }),
|
||||
).not.toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
|
||||
@@ -210,7 +226,12 @@ describe.each([
|
||||
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
|
||||
|
||||
// For traces, column scope is always Selected and the radio is hidden
|
||||
if (dataSource !== DataSource.TRACES) {
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -227,6 +248,11 @@ describe.each([
|
||||
});
|
||||
|
||||
it('sends no selectFields when column scope is All', async () => {
|
||||
// For traces, column scope is always Selected — this test only applies to other sources
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderWithStore(dataSource);
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -14,10 +15,12 @@ import './DownloadOptionsMenu.styles.scss';
|
||||
|
||||
interface DownloadOptionsMenuProps {
|
||||
dataSource: DataSource;
|
||||
selectedColumns?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
export default function DownloadOptionsMenu({
|
||||
dataSource,
|
||||
selectedColumns,
|
||||
}: DownloadOptionsMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
|
||||
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
|
||||
@@ -35,9 +38,19 @@ export default function DownloadOptionsMenu({
|
||||
await handleExportRawData({
|
||||
format: exportFormat,
|
||||
rowLimit,
|
||||
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
|
||||
clearSelectColumns:
|
||||
dataSource !== DataSource.TRACES &&
|
||||
columnsScope === DownloadColumnsScopes.ALL,
|
||||
selectedColumns,
|
||||
});
|
||||
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
|
||||
}, [
|
||||
exportFormat,
|
||||
rowLimit,
|
||||
columnsScope,
|
||||
selectedColumns,
|
||||
handleExportRawData,
|
||||
dataSource,
|
||||
]);
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() => (
|
||||
@@ -72,18 +85,22 @@ export default function DownloadOptionsMenu({
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div className="horizontal-line" />
|
||||
{dataSource !== DataSource.TRACES && (
|
||||
<>
|
||||
<div className="horizontal-line" />
|
||||
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<Radio.Group
|
||||
value={columnsScope}
|
||||
onChange={(e): void => setColumnsScope(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
|
||||
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<Radio.Group
|
||||
value={columnsScope}
|
||||
onChange={(e): void => setColumnsScope(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
|
||||
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -97,7 +114,14 @@ export default function DownloadOptionsMenu({
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
|
||||
[
|
||||
exportFormat,
|
||||
rowLimit,
|
||||
columnsScope,
|
||||
isDownloading,
|
||||
handleExport,
|
||||
dataSource,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,9 +16,9 @@ function AverageResolutionCard({
|
||||
}: TotalTriggeredCardProps): JSX.Element {
|
||||
return (
|
||||
<StatsCard
|
||||
displayValue={formatTime(currentAvgResolutionTime)}
|
||||
totalCurrentCount={currentAvgResolutionTime}
|
||||
totalPastCount={pastAvgResolutionTime}
|
||||
displayValue={formatTime(+currentAvgResolutionTime)}
|
||||
totalCurrentCount={+currentAvgResolutionTime}
|
||||
totalPastCount={+pastAvgResolutionTime}
|
||||
title="Avg. Resolution Time"
|
||||
timeSeries={timeSeries}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,7 @@ import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import GridCard from './GridCard';
|
||||
import { Card, CardContainer, ReactGridLayout } from './styles';
|
||||
import {
|
||||
applyRowCollapse,
|
||||
hasColumnWidthsChanged,
|
||||
removeUndefinedValuesFromLayout,
|
||||
} from './utils';
|
||||
@@ -268,13 +269,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
currentWidget.title = newTitle;
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
|
||||
(e) => e.id !== currentSelectRowId,
|
||||
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
|
||||
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
|
||||
);
|
||||
|
||||
updatedWidgets?.push(currentWidget);
|
||||
|
||||
const updatedSelectedDashboard: Props = {
|
||||
id: selectedDashboard.id,
|
||||
data: {
|
||||
@@ -316,88 +314,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
if (!selectedDashboard) {
|
||||
return;
|
||||
}
|
||||
const rowProperties = { ...currentPanelMap[id] };
|
||||
const updatedPanelMap = { ...currentPanelMap };
|
||||
|
||||
let updatedDashboardLayout = [...dashboardLayout];
|
||||
if (rowProperties.collapsed === true) {
|
||||
rowProperties.collapsed = false;
|
||||
const widgetsInsideTheRow = rowProperties.widgets;
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
|
||||
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
|
||||
|
||||
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j].y += maxY;
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({
|
||||
...w,
|
||||
y: w.y + maxY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
|
||||
} else {
|
||||
rowProperties.collapsed = true;
|
||||
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
|
||||
|
||||
let widgetsInsideTheRow: Layout[] = [];
|
||||
let isPanelMapUpdated = false;
|
||||
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
|
||||
if (currentPanelMap[dashboardLayout[j].i]) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
widgetsInsideTheRow = [];
|
||||
isPanelMapUpdated = true;
|
||||
break;
|
||||
} else {
|
||||
widgetsInsideTheRow.push(dashboardLayout[j]);
|
||||
}
|
||||
}
|
||||
if (!isPanelMapUpdated) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
}
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout[currentIdx];
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j].y += maxY;
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({
|
||||
...w,
|
||||
y: w.y + maxY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
updatedDashboardLayout = updatedDashboardLayout.filter(
|
||||
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
|
||||
);
|
||||
}
|
||||
setCurrentPanelMap((prev) => ({
|
||||
...prev,
|
||||
...updatedPanelMap,
|
||||
[id]: {
|
||||
...rowProperties,
|
||||
},
|
||||
}));
|
||||
|
||||
setDashboardLayout(sortLayout(updatedDashboardLayout));
|
||||
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
|
||||
id,
|
||||
dashboardLayout,
|
||||
currentPanelMap,
|
||||
);
|
||||
setCurrentPanelMap((prev) => ({ ...prev, ...updatedPanelMap }));
|
||||
setDashboardLayout(sortLayout(updatedLayout));
|
||||
};
|
||||
|
||||
const handleDragStop: ItemCallback = (_, oldItem, newItem): void => {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Layout } from 'react-grid-layout';
|
||||
|
||||
import { applyRowCollapse, PanelMap } from '../utils';
|
||||
|
||||
// Helper to produce deeply-frozen objects that mimic what zustand/immer returns.
|
||||
function freeze<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj), (_, v) =>
|
||||
typeof v === 'object' && v !== null ? Object.freeze(v) : v,
|
||||
) as T;
|
||||
}
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW_ID = 'row1';
|
||||
|
||||
/** A layout with one row followed by two widgets. */
|
||||
function makeLayout(): Layout[] {
|
||||
return [
|
||||
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
|
||||
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
|
||||
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
/** panelMap where the row is expanded (collapsed = false, widgets = []). */
|
||||
function makeExpandedPanelMap(): PanelMap {
|
||||
return {
|
||||
[ROW_ID]: { collapsed: false, widgets: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** panelMap where the row is collapsed (widgets stored inside). */
|
||||
function makeCollapsedPanelMap(): PanelMap {
|
||||
return {
|
||||
[ROW_ID]: {
|
||||
collapsed: true,
|
||||
widgets: [
|
||||
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
|
||||
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── frozen-input guard (regression for zustand/immer read-only bug) ──────────
|
||||
|
||||
describe('applyRowCollapse – does not mutate frozen inputs', () => {
|
||||
it('does not throw when collapsing a row with frozen layout + panelMap', () => {
|
||||
expect(() =>
|
||||
applyRowCollapse(
|
||||
ROW_ID,
|
||||
freeze(makeLayout()),
|
||||
freeze(makeExpandedPanelMap()),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when expanding a row with frozen layout + panelMap', () => {
|
||||
// Collapsed layout only has the row item; widgets live in panelMap.
|
||||
const collapsedLayout = freeze([{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }]);
|
||||
expect(() =>
|
||||
applyRowCollapse(ROW_ID, collapsedLayout, freeze(makeCollapsedPanelMap())),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('leaves the original layout array untouched after collapse', () => {
|
||||
const layout = makeLayout();
|
||||
const originalY = layout[1].y; // w1.y before collapse
|
||||
applyRowCollapse(ROW_ID, layout, makeExpandedPanelMap());
|
||||
expect(layout[1].y).toBe(originalY);
|
||||
});
|
||||
|
||||
it('leaves the original panelMap untouched after collapse', () => {
|
||||
const panelMap = makeExpandedPanelMap();
|
||||
applyRowCollapse(ROW_ID, makeLayout(), panelMap);
|
||||
expect(panelMap[ROW_ID].collapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── collapse behaviour ───────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRowCollapse – collapsing a row', () => {
|
||||
it('sets collapsed = true on the row entry', () => {
|
||||
const { updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
makeLayout(),
|
||||
makeExpandedPanelMap(),
|
||||
);
|
||||
expect(updatedPanelMap[ROW_ID].collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('stores the child widgets inside the panelMap entry', () => {
|
||||
const { updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
makeLayout(),
|
||||
makeExpandedPanelMap(),
|
||||
);
|
||||
const ids = updatedPanelMap[ROW_ID].widgets.map((w) => w.i);
|
||||
expect(ids).toContain('w1');
|
||||
expect(ids).toContain('w2');
|
||||
});
|
||||
|
||||
it('removes child widgets from the returned layout', () => {
|
||||
const { updatedLayout } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
makeLayout(),
|
||||
makeExpandedPanelMap(),
|
||||
);
|
||||
const ids = updatedLayout.map((l) => l.i);
|
||||
expect(ids).not.toContain('w1');
|
||||
expect(ids).not.toContain('w2');
|
||||
expect(ids).toContain(ROW_ID);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── expand behaviour ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRowCollapse – expanding a row', () => {
|
||||
it('sets collapsed = false on the row entry', () => {
|
||||
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
|
||||
const { updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
collapsedLayout,
|
||||
makeCollapsedPanelMap(),
|
||||
);
|
||||
expect(updatedPanelMap[ROW_ID].collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it('restores child widgets to the returned layout', () => {
|
||||
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
|
||||
const { updatedLayout } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
collapsedLayout,
|
||||
makeCollapsedPanelMap(),
|
||||
);
|
||||
const ids = updatedLayout.map((l) => l.i);
|
||||
expect(ids).toContain('w1');
|
||||
expect(ids).toContain('w2');
|
||||
});
|
||||
|
||||
it('restored child widgets appear in both the layout and the panelMap entry', () => {
|
||||
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
|
||||
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
|
||||
ROW_ID,
|
||||
collapsedLayout,
|
||||
makeCollapsedPanelMap(),
|
||||
);
|
||||
// The previously-stored widgets should now be back in the live layout.
|
||||
expect(updatedLayout.map((l) => l.i)).toContain('w1');
|
||||
// The panelMap entry still holds a reference to them (stale until next collapse).
|
||||
expect(updatedPanelMap[ROW_ID].widgets.map((w) => w.i)).toContain('w1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── y-offset adjustment ──────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRowCollapse – y-offset adjustments for rows below', () => {
|
||||
it('shifts items below a second row down when the first row expands', () => {
|
||||
const ROW2 = 'row2';
|
||||
// Layout: row1 (y=0,h=1) | w1 (y=1,h=4) | row2 (y=5,h=1) | w3 (y=6,h=2)
|
||||
const layout: Layout[] = [
|
||||
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
|
||||
{ i: 'w1', x: 0, y: 1, w: 12, h: 4 },
|
||||
{ i: ROW2, x: 0, y: 5, w: 12, h: 1 },
|
||||
{ i: 'w3', x: 0, y: 6, w: 12, h: 2 },
|
||||
];
|
||||
const panelMap: PanelMap = {
|
||||
[ROW_ID]: {
|
||||
collapsed: true,
|
||||
widgets: [{ i: 'w1', x: 0, y: 1, w: 12, h: 4 }],
|
||||
},
|
||||
[ROW2]: { collapsed: false, widgets: [] },
|
||||
};
|
||||
// Expanding row1 should push row2 and w3 down by the height of w1 (4).
|
||||
const collapsedLayout = layout.filter((l) => l.i !== 'w1');
|
||||
const { updatedLayout } = applyRowCollapse(ROW_ID, collapsedLayout, panelMap);
|
||||
|
||||
const row2Item = updatedLayout.find((l) => l.i === ROW2);
|
||||
expect(row2Item?.y).toBe(5 + 4); // shifted by maxY = 4
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,122 @@ import { isEmpty, isEqual } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type PanelMap = Record<
|
||||
string,
|
||||
{ widgets: Layout[]; collapsed: boolean }
|
||||
>;
|
||||
|
||||
export interface RowCollapseResult {
|
||||
updatedLayout: Layout[];
|
||||
updatedPanelMap: PanelMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that computes the new layout and panelMap after toggling a
|
||||
* row's collapsed state. All inputs are treated as immutable — no input object
|
||||
* is mutated, so it is safe to pass frozen objects from the zustand store.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyRowCollapse(
|
||||
id: string,
|
||||
dashboardLayout: Layout[],
|
||||
currentPanelMap: PanelMap,
|
||||
): RowCollapseResult {
|
||||
// Deep-copy the row's own properties so we can mutate our local copy.
|
||||
const rowProperties = {
|
||||
...currentPanelMap[id],
|
||||
widgets: [...(currentPanelMap[id]?.widgets ?? [])],
|
||||
};
|
||||
|
||||
// Shallow-copy each entry's widgets array so inner .map() calls are safe.
|
||||
const updatedPanelMap: PanelMap = Object.fromEntries(
|
||||
Object.entries(currentPanelMap).map(([k, v]) => [
|
||||
k,
|
||||
{ ...v, widgets: [...v.widgets] },
|
||||
]),
|
||||
);
|
||||
|
||||
let updatedDashboardLayout = [...dashboardLayout];
|
||||
|
||||
if (rowProperties.collapsed === true) {
|
||||
// ── EXPAND ──────────────────────────────────────────────────────────────
|
||||
rowProperties.collapsed = false;
|
||||
const widgetsInsideTheRow = rowProperties.widgets;
|
||||
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
|
||||
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
|
||||
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j] = {
|
||||
...updatedDashboardLayout[j],
|
||||
y: updatedDashboardLayout[j].y + maxY,
|
||||
};
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
|
||||
}
|
||||
}
|
||||
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
|
||||
} else {
|
||||
// ── COLLAPSE ─────────────────────────────────────────────────────────────
|
||||
rowProperties.collapsed = true;
|
||||
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
|
||||
|
||||
let widgetsInsideTheRow: Layout[] = [];
|
||||
let isPanelMapUpdated = false;
|
||||
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
|
||||
if (currentPanelMap[dashboardLayout[j].i]) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
widgetsInsideTheRow = [];
|
||||
isPanelMapUpdated = true;
|
||||
break;
|
||||
} else {
|
||||
widgetsInsideTheRow.push(dashboardLayout[j]);
|
||||
}
|
||||
}
|
||||
if (!isPanelMapUpdated) {
|
||||
rowProperties.widgets = widgetsInsideTheRow;
|
||||
}
|
||||
|
||||
let maxY = 0;
|
||||
widgetsInsideTheRow.forEach((w) => {
|
||||
maxY = Math.max(maxY, w.y + w.h);
|
||||
});
|
||||
const currentRowWidget = dashboardLayout[currentIdx];
|
||||
if (currentRowWidget && widgetsInsideTheRow.length) {
|
||||
maxY -= currentRowWidget.h + currentRowWidget.y;
|
||||
}
|
||||
|
||||
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
|
||||
updatedDashboardLayout[j] = {
|
||||
...updatedDashboardLayout[j],
|
||||
y: updatedDashboardLayout[j].y + maxY,
|
||||
};
|
||||
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
|
||||
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
|
||||
updatedDashboardLayout[j].i
|
||||
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
|
||||
}
|
||||
}
|
||||
|
||||
updatedDashboardLayout = updatedDashboardLayout.filter(
|
||||
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
|
||||
);
|
||||
}
|
||||
|
||||
updatedPanelMap[id] = { ...rowProperties };
|
||||
|
||||
return { updatedLayout: updatedDashboardLayout, updatedPanelMap };
|
||||
}
|
||||
|
||||
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
|
||||
layout.map((obj) =>
|
||||
Object.fromEntries(
|
||||
|
||||
@@ -92,7 +92,10 @@ function LogsActionsContainer({
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu dataSource={DataSource.LOGS} />
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
|
||||
@@ -42,8 +42,15 @@ function LogsPanelComponent({
|
||||
setPageSize(value);
|
||||
setOffset(0);
|
||||
setRequestData((prev) => {
|
||||
const newQueryData = { ...prev.query };
|
||||
newQueryData.builder.queryData[0].pageSize = value;
|
||||
const newQueryData = {
|
||||
...prev.query,
|
||||
builder: {
|
||||
...prev.query.builder,
|
||||
queryData: prev.query.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: value } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
query: newQueryData,
|
||||
|
||||
@@ -42,11 +42,19 @@ function Panel({
|
||||
};
|
||||
}
|
||||
|
||||
updatedQuery.builder.queryData[0].pageSize = 10;
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
const updatedQueryForList = {
|
||||
...updatedQuery,
|
||||
builder: {
|
||||
...updatedQuery.builder,
|
||||
queryData: updatedQuery.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: 10 } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
query: updatedQuery,
|
||||
query: updatedQueryForList,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
|
||||
@@ -239,7 +239,10 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu dataSource={DataSource.TRACES} />
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
|
||||
@@ -52,37 +52,44 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
!firstQueryData?.filters?.items.some((filter) => filter.key?.key === 'id') &&
|
||||
firstQueryData?.orderBy[0].columnName === 'timestamp';
|
||||
|
||||
const modifiedRequestData = {
|
||||
if (
|
||||
isListWithSingleTimestampOrder &&
|
||||
firstQueryData?.dataSource === DataSource.LOGS
|
||||
) {
|
||||
return {
|
||||
...requestData,
|
||||
graphType:
|
||||
requestData.graphType === PANEL_TYPES.BAR
|
||||
? PANEL_TYPES.TIME_SERIES
|
||||
: requestData.graphType,
|
||||
query: {
|
||||
...requestData.query,
|
||||
builder: {
|
||||
...requestData.query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...firstQueryData,
|
||||
orderBy: [
|
||||
...(firstQueryData?.orderBy || []),
|
||||
{
|
||||
columnName: 'id',
|
||||
order: firstQueryData?.orderBy[0]?.order,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...requestData,
|
||||
graphType:
|
||||
requestData.graphType === PANEL_TYPES.BAR
|
||||
? PANEL_TYPES.TIME_SERIES
|
||||
: requestData.graphType,
|
||||
};
|
||||
|
||||
// If the query is a list with a single timestamp order, we need to add the id column to the order by clause
|
||||
if (
|
||||
isListWithSingleTimestampOrder &&
|
||||
firstQueryData?.dataSource === DataSource.LOGS
|
||||
) {
|
||||
modifiedRequestData.query.builder = {
|
||||
...requestData.query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...firstQueryData,
|
||||
orderBy: [
|
||||
...(firstQueryData?.orderBy || []),
|
||||
{
|
||||
columnName: 'id',
|
||||
order: firstQueryData?.orderBy[0]?.order,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return modifiedRequestData;
|
||||
}, [requestData]);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { message } from 'antd';
|
||||
import { downloadExportData } from 'api/v1/download/downloadExportData';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { prepareQueryRangePayloadV5, TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -14,6 +14,7 @@ interface ExportOptions {
|
||||
format: string;
|
||||
rowLimit: number;
|
||||
clearSelectColumns: boolean;
|
||||
selectedColumns?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
interface UseExportRawDataProps {
|
||||
@@ -42,6 +43,7 @@ export function useExportRawData({
|
||||
format,
|
||||
rowLimit,
|
||||
clearSelectColumns,
|
||||
selectedColumns,
|
||||
}: ExportOptions): Promise<void> => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
@@ -50,6 +52,12 @@ export function useExportRawData({
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
const selectColumnsOverride = clearSelectColumns
|
||||
? {}
|
||||
: selectedColumns?.length
|
||||
? { selectColumns: selectedColumns }
|
||||
: {};
|
||||
|
||||
const exportQuery = {
|
||||
...stagedQuery,
|
||||
builder: {
|
||||
@@ -59,7 +67,7 @@ export function useExportRawData({
|
||||
groupBy: [],
|
||||
having: { expression: '' },
|
||||
limit: rowLimit,
|
||||
...(clearSelectColumns && { selectColumns: [] }),
|
||||
...selectColumnsOverride,
|
||||
})),
|
||||
queryTraceOperator: (stagedQuery.builder.queryTraceOperator || []).map(
|
||||
(traceOp) => ({
|
||||
@@ -67,7 +75,7 @@ export function useExportRawData({
|
||||
groupBy: [],
|
||||
having: { expression: '' },
|
||||
limit: rowLimit,
|
||||
...(clearSelectColumns && { selectColumns: [] }),
|
||||
...selectColumnsOverride,
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -56,8 +56,8 @@ export interface AlertRuleStats {
|
||||
totalPastTriggers: number;
|
||||
currentTriggersSeries: CurrentTriggersSeries;
|
||||
pastTriggersSeries: CurrentTriggersSeries | null;
|
||||
currentAvgResolutionTime: number;
|
||||
pastAvgResolutionTime: number;
|
||||
currentAvgResolutionTime: string;
|
||||
pastAvgResolutionTime: string;
|
||||
currentAvgResolutionTimeSeries: CurrentTriggersSeries;
|
||||
pastAvgResolutionTimeSeries: any | null;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ export function formatEpochTimestamp(epoch: number): string {
|
||||
*/
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
seconds = +seconds;
|
||||
|
||||
if (Number.isNaN(seconds)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const days = seconds / 86400;
|
||||
|
||||
if (days >= 1) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tracedetail
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
@@ -63,26 +64,22 @@ func findIndexForSelectedSpanFromPreOrder(spans []*model.Span, selectedSpanId st
|
||||
return selectedSpanIndex
|
||||
}
|
||||
|
||||
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, uncollapsedSpans []string, isSelectedSpanIDUnCollapsed bool) (bool, []string) {
|
||||
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string) (bool, []string) {
|
||||
spansFromRootToNode := []string{}
|
||||
|
||||
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
|
||||
if node.SpanID == selectedSpanId {
|
||||
if isSelectedSpanIDUnCollapsed && !slices.Contains(uncollapsedSpans, node.SpanID) {
|
||||
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
|
||||
}
|
||||
return true, spansFromRootToNode
|
||||
}
|
||||
|
||||
isPresentInSubtreeForTheNode := false
|
||||
for _, child := range node.Children {
|
||||
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId, uncollapsedSpans, isSelectedSpanIDUnCollapsed)
|
||||
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId)
|
||||
// if the interested node is present in the given subtree then add the span node to uncollapsed node list
|
||||
if isPresentInThisSubtree {
|
||||
if !slices.Contains(uncollapsedSpans, node.SpanID) {
|
||||
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
|
||||
}
|
||||
isPresentInSubtreeForTheNode = true
|
||||
spansFromRootToNode = append(spansFromRootToNode, _spansFromRootToNode...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return isPresentInSubtreeForTheNode, spansFromRootToNode
|
||||
@@ -92,7 +89,7 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, un
|
||||
// throughout the recursion. Per-call state (level, isPartOfPreOrder, etc.)
|
||||
// is passed as direct arguments.
|
||||
type traverseOpts struct {
|
||||
uncollapsedSpans []string
|
||||
uncollapsedSpans map[string]struct{}
|
||||
selectedSpanID string
|
||||
}
|
||||
|
||||
@@ -134,7 +131,7 @@ func traverseTrace(span *model.Span, opts traverseOpts, level uint64, isPartOfPr
|
||||
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
|
||||
}
|
||||
|
||||
isAlreadyUncollapsed := slices.Contains(opts.uncollapsedSpans, span.SpanID)
|
||||
_, isAlreadyUncollapsed := opts.uncollapsedSpans[span.SpanID]
|
||||
for index, child := range span.Children {
|
||||
_childTraversal := traverseTrace(child, opts, level+1, isPartOfPreOrder && isAlreadyUncollapsed, index != (len(span.Children)-1))
|
||||
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
|
||||
@@ -169,16 +166,28 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
|
||||
|
||||
var preOrderTraversal = make([]*model.Span, 0)
|
||||
var rootServiceName, rootServiceEntryPoint string
|
||||
updatedUncollapsedSpans := uncollapsedSpans
|
||||
|
||||
// create a map of uncollapsed spans for quick lookup
|
||||
uncollapsedSpanMap := make(map[string]struct{})
|
||||
for _, spanID := range uncollapsedSpans {
|
||||
uncollapsedSpanMap[spanID] = struct{}{}
|
||||
}
|
||||
|
||||
selectedSpanIndex := -1
|
||||
for _, rootSpanID := range traceRoots {
|
||||
if rootNode, exists := spanIdToSpanNodeMap[rootSpanID.SpanID]; exists {
|
||||
_, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID, updatedUncollapsedSpans, isSelectedSpanIDUnCollapsed)
|
||||
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spansFromRootToNode...)
|
||||
present, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID)
|
||||
if present {
|
||||
for _, spanID := range spansFromRootToNode {
|
||||
if selectedSpanID == spanID && !isSelectedSpanIDUnCollapsed {
|
||||
continue
|
||||
}
|
||||
uncollapsedSpanMap[spanID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
opts := traverseOpts{
|
||||
uncollapsedSpans: updatedUncollapsedSpans,
|
||||
uncollapsedSpans: uncollapsedSpanMap,
|
||||
selectedSpanID: selectedSpanID,
|
||||
}
|
||||
_preOrderTraversal := traverseTrace(rootNode, opts, 0, true, false)
|
||||
@@ -223,5 +232,5 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
|
||||
startIndex = 0
|
||||
}
|
||||
|
||||
return preOrderTraversal[startIndex:endIndex], updatedUncollapsedSpans, rootServiceName, rootServiceEntryPoint
|
||||
return preOrderTraversal[startIndex:endIndex], slices.Collect(maps.Keys(uncollapsedSpanMap)), rootServiceName, rootServiceEntryPoint
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ func TestGetSelectedSpans_PathReturnedInUncollapsed(t *testing.T) {
|
||||
spanMap := buildSpanMap(root)
|
||||
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
|
||||
|
||||
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
|
||||
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
|
||||
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestGetSelectedSpans_SiblingsNotExpanded(t *testing.T) {
|
||||
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
|
||||
assert.Equal(t, []string{"root", "parent", "selected", "unrelated"}, spanIDs(spans))
|
||||
// only the path nodes are tracked as uncollapsed — unrelated is not
|
||||
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
|
||||
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
|
||||
}
|
||||
|
||||
// An unknown selectedSpanID must not panic; returns a window from index 0.
|
||||
|
||||
@@ -3,7 +3,9 @@ package telemetrylogs
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -32,9 +34,25 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
|
||||
|
||||
// BuildCondition builds the full WHERE condition for body_v2 JSON paths
|
||||
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
baseCond, err := c.emitPlannedCondition(operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// path index
|
||||
if operator.AddDefaultExistsFilter() {
|
||||
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
|
||||
return sb.And(baseCond, pathIndex), nil
|
||||
}
|
||||
|
||||
return baseCond, nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
// Build traversal + terminal recursively per-hop
|
||||
conditions := []string{}
|
||||
for _, node := range c.key.JSONPlan {
|
||||
condition, err := c.emitPlannedCondition(node, operator, value, sb)
|
||||
condition, err := c.recurseArrayHops(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -44,164 +62,7 @@ func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperato
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
// emitPlannedCondition handles paths with array traversal
|
||||
func (c *jsonConditionBuilder) emitPlannedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
// Build traversal + terminal recursively per-hop
|
||||
compiled, err := c.recurseArrayHops(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
// buildTerminalCondition creates the innermost condition
|
||||
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
conditions := []string{}
|
||||
// if the value type is not an array
|
||||
// TODO(piyush): Confirm the Query built for Array case and add testcases for it later
|
||||
if !c.valueType.IsArray {
|
||||
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
}
|
||||
|
||||
// switch operator for array membership checks
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorContains:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
}
|
||||
}
|
||||
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
// or the conditions together
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
// it handles the data type collisions and utilizes indexes for the condition if available
|
||||
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
var formattedValue any = value
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == elemType && index.ColumnExpression == fieldPath
|
||||
})
|
||||
if elemType.IndexSupported && indexed {
|
||||
indexedExpr := assumeNotNull(fieldPath, elemType)
|
||||
emptyValue := func() any {
|
||||
switch elemType {
|
||||
case telemetrytypes.String:
|
||||
return ""
|
||||
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
|
||||
return 0
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = emptyValue
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = emptyValue
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr, indexedComparisonValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, indexedComparisonValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != emptyValue {
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
conditions = append(conditions, cond)
|
||||
// Switch operator to EXISTS since indexed paths on assumedNotNull, indexes will always have a default value
|
||||
// So we flip the operator to Exists and filter the rows that actually have the value
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
// buildArrayMembershipCondition handles array membership checks
|
||||
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
arrayPath := node.FieldPath()
|
||||
localKeyCopy := *node.TerminalConfig.Key
|
||||
// create typed array out of a dynamic array
|
||||
filteredDynamicExpr := func() string {
|
||||
// Change the field data type from []dynamic to the value type
|
||||
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
|
||||
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
|
||||
|
||||
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
|
||||
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
|
||||
c.valueType.StringValue(),
|
||||
c.valueType.StringValue(),
|
||||
baseArrayDynamicExpr)
|
||||
}
|
||||
typedArrayExpr := func() string {
|
||||
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
|
||||
}
|
||||
|
||||
var arrayExpr string
|
||||
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
|
||||
arrayExpr = filteredDynamicExpr()
|
||||
} else {
|
||||
arrayExpr = typedArrayExpr()
|
||||
}
|
||||
|
||||
key := "x"
|
||||
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
|
||||
op, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
// recurseArrayHops recursively builds array traversal conditions
|
||||
// buildPlanCondition recursively traverses a single JSONPlan and builds condition
|
||||
func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
@@ -215,6 +76,33 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
|
||||
return terminalCond, nil
|
||||
}
|
||||
|
||||
// apply NOT at top level arrayExists so that any subsequent arrayExists fails we count it as true (matching log)
|
||||
yes, operator := applyNotCondition(operator)
|
||||
condition, err := c.buildAccessNodeBranches(current, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if yes {
|
||||
return sb.Not(condition), nil
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOperator) {
|
||||
if operator.IsNegativeOperator() {
|
||||
return true, operator.Inverse()
|
||||
}
|
||||
return false, operator
|
||||
}
|
||||
|
||||
// buildAccessNodeBranches builds conditions for each branch of the access node
|
||||
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if current == nil {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
|
||||
}
|
||||
|
||||
currAlias := current.Alias()
|
||||
fieldPath := current.FieldPath()
|
||||
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
|
||||
@@ -249,6 +137,213 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
|
||||
return sb.Or(branches...), nil
|
||||
}
|
||||
|
||||
// buildTerminalCondition creates the innermost condition
|
||||
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
if node.TerminalConfig.ElemType.IsArray {
|
||||
// Note: here applyNotCondition will return true only if; top level path is an array; and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
yes, operator := applyNotCondition(operator)
|
||||
cond, err := c.buildTerminalArrayCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if yes {
|
||||
return sb.Not(cond), nil
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
|
||||
}
|
||||
|
||||
func getEmptyValue(elemType telemetrytypes.JSONDataType) any {
|
||||
switch elemType {
|
||||
case telemetrytypes.String:
|
||||
return ""
|
||||
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
|
||||
return 0
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
if strings.Contains(fieldPath, telemetrytypes.ArraySepSuffix) {
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
indexedExpr := assumeNotNull(dynamicExpr)
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = getEmptyValue(elemType)
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = getEmptyValue(elemType)
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
// it handles the data type collisions and utilizes indexes for the condition if available
|
||||
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
//
|
||||
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == node.TerminalConfig.ElemType
|
||||
})
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed {
|
||||
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
|
||||
return indexCond, nil
|
||||
}
|
||||
|
||||
conditions = append(conditions, indexCond)
|
||||
|
||||
// Switch operator to EXISTS except when operator is NOT EXISTS since
|
||||
// indexed paths on assumedNotNull, indexes will always have a default
|
||||
// value so we flip the operator to Exists and filter the rows that
|
||||
// actually have the value
|
||||
if operator != qbtypes.FilterOperatorNotExists {
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
}
|
||||
|
||||
var formattedValue any = value
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
|
||||
|
||||
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
|
||||
//
|
||||
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
if node.IsNonNestedPath() {
|
||||
yes, _ := applyNotCondition(operator)
|
||||
if yes {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
// skip
|
||||
default:
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.And(conditions...), nil
|
||||
}
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) buildTerminalArrayCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
conditions := []string{}
|
||||
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
|
||||
if operator.IsStringSearchOperator() {
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
|
||||
// switch operator for array membership checks
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorContains:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
}
|
||||
}
|
||||
|
||||
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, arrayCond)
|
||||
if len(conditions) > 1 {
|
||||
return sb.Or(conditions...), nil
|
||||
}
|
||||
|
||||
return conditions[0], nil
|
||||
}
|
||||
|
||||
// buildArrayMembershipCondition builds condition of the part where Arrays becomes primitive typed Arrays
|
||||
// e.g. [300, 404, 500], and value operations will work on the array elements
|
||||
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
arrayPath := node.FieldPath()
|
||||
localKeyCopy := *node.TerminalConfig.Key
|
||||
// create typed array out of a dynamic array
|
||||
filteredDynamicExpr := func() string {
|
||||
// Change the field data type from []dynamic to the value type
|
||||
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
|
||||
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
|
||||
|
||||
primitiveType := c.valueType.StringValue()
|
||||
// check if value is an array
|
||||
if c.valueType.IsArray {
|
||||
primitiveType = c.valueType.ScalerType
|
||||
}
|
||||
|
||||
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
|
||||
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
|
||||
primitiveType,
|
||||
primitiveType,
|
||||
baseArrayDynamicExpr)
|
||||
}
|
||||
typedArrayExpr := func() string {
|
||||
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
|
||||
}
|
||||
|
||||
var arrayExpr string
|
||||
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
|
||||
arrayExpr = filteredDynamicExpr()
|
||||
} else {
|
||||
arrayExpr = typedArrayExpr()
|
||||
}
|
||||
|
||||
key := "x"
|
||||
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
|
||||
op, err := c.applyOperator(sb, fieldExpr, operator, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
|
||||
}
|
||||
|
||||
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
@@ -310,6 +405,6 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
|
||||
}
|
||||
}
|
||||
|
||||
func assumeNotNull(column string, elemType telemetrytypes.JSONDataType) string {
|
||||
return fmt.Sprintf("assumeNotNull(dynamicElement(%s, '%s'))", column, elemType.StringValue())
|
||||
func assumeNotNull(fieldExpr string) string {
|
||||
return fmt.Sprintf("assumeNotNull(%s)", fieldExpr)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -113,6 +113,29 @@ const (
|
||||
FilterOperatorNotContains
|
||||
)
|
||||
|
||||
var operatorInverseMapping = map[FilterOperator]FilterOperator{
|
||||
FilterOperatorEqual: FilterOperatorNotEqual,
|
||||
FilterOperatorNotEqual: FilterOperatorEqual,
|
||||
FilterOperatorGreaterThan: FilterOperatorLessThanOrEq,
|
||||
FilterOperatorGreaterThanOrEq: FilterOperatorLessThan,
|
||||
FilterOperatorLessThan: FilterOperatorGreaterThanOrEq,
|
||||
FilterOperatorLessThanOrEq: FilterOperatorGreaterThan,
|
||||
FilterOperatorLike: FilterOperatorNotLike,
|
||||
FilterOperatorNotLike: FilterOperatorLike,
|
||||
FilterOperatorILike: FilterOperatorNotILike,
|
||||
FilterOperatorNotILike: FilterOperatorILike,
|
||||
FilterOperatorBetween: FilterOperatorNotBetween,
|
||||
FilterOperatorNotBetween: FilterOperatorBetween,
|
||||
FilterOperatorIn: FilterOperatorNotIn,
|
||||
FilterOperatorNotIn: FilterOperatorIn,
|
||||
FilterOperatorExists: FilterOperatorNotExists,
|
||||
FilterOperatorNotExists: FilterOperatorExists,
|
||||
FilterOperatorRegexp: FilterOperatorNotRegexp,
|
||||
FilterOperatorNotRegexp: FilterOperatorRegexp,
|
||||
FilterOperatorContains: FilterOperatorNotContains,
|
||||
FilterOperatorNotContains: FilterOperatorContains,
|
||||
}
|
||||
|
||||
// AddDefaultExistsFilter returns true if addl exists filter should be added to the query
|
||||
// For the negative predicates, we don't want to add the exists filter. Why?
|
||||
// Say for example, user adds a filter `service.name != "redis"`, we can't interpret it
|
||||
@@ -162,6 +185,10 @@ func (f FilterOperator) IsNegativeOperator() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (f FilterOperator) Inverse() FilterOperator {
|
||||
return operatorInverseMapping[f]
|
||||
}
|
||||
|
||||
func (f FilterOperator) IsComparisonOperator() bool {
|
||||
switch f {
|
||||
case FilterOperatorGreaterThan, FilterOperatorGreaterThanOrEq, FilterOperatorLessThan, FilterOperatorLessThanOrEq:
|
||||
|
||||
@@ -102,6 +102,10 @@ func (fn FunctionName) Validate() error {
|
||||
|
||||
// ApplyFunction applies the given function to the result data
|
||||
func ApplyFunction(fn Function, result *TimeSeries) *TimeSeries {
|
||||
if len(result.Values) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// Extract the function name and arguments
|
||||
name := fn.Name
|
||||
args := fn.Args
|
||||
|
||||
@@ -599,6 +599,14 @@ func TestApplyFunction(t *testing.T) {
|
||||
values []float64
|
||||
want []float64
|
||||
}{
|
||||
{
|
||||
name: "test with empty series",
|
||||
function: Function{
|
||||
Name: FunctionNameRunningDiff,
|
||||
},
|
||||
values: []float64{},
|
||||
want: []float64{},
|
||||
},
|
||||
{
|
||||
name: "cutOffMin function",
|
||||
function: Function{
|
||||
|
||||
@@ -206,8 +206,11 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// At least one aggregation required for non-disabled queries
|
||||
if len(q.Aggregations) == 0 && !q.Disabled {
|
||||
// At least one aggregation required for aggregation queries, even if
|
||||
// they are disabled, usually because they are used in formula
|
||||
// regardless of use in formula, it's invalid to have empty Aggregations
|
||||
// for aggregation request
|
||||
if len(q.Aggregations) == 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"at least one aggregation is required",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -31,7 +32,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "test",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationMax,
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -39,7 +47,12 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []LogAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -61,7 +74,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "test",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationMax,
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,7 +214,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "test",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationMax,
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -232,7 +259,12 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []LogAggregation{
|
||||
{
|
||||
Expression: "sum(duration)",
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -366,7 +398,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []LogAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -374,7 +411,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -396,7 +438,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "X",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []LogAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -404,7 +451,14 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "X",
|
||||
Disabled: true,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []MetricAggregation{
|
||||
{
|
||||
MetricName: "test",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationMax,
|
||||
},
|
||||
},
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -427,7 +481,9 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []LogAggregation{
|
||||
{Expression: "count()"},
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -581,7 +637,9 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []LogAggregation{
|
||||
{Expression: "count()"},
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -90,6 +90,11 @@ func (n *JSONAccessNode) FieldPath() string {
|
||||
return n.Parent.Alias() + "." + key
|
||||
}
|
||||
|
||||
// Returns true if the current node is a non-nested path
|
||||
func (n *JSONAccessNode) IsNonNestedPath() bool {
|
||||
return !strings.Contains(n.FieldPath(), ArraySep)
|
||||
}
|
||||
|
||||
func (n *JSONAccessNode) BranchesInOrder() []JSONAccessBranchType {
|
||||
return slices.SortedFunc(maps.Keys(n.Branches), func(a, b JSONAccessBranchType) int {
|
||||
return strings.Compare(b.StringValue(), a.StringValue())
|
||||
|
||||
@@ -4,69 +4,106 @@ package telemetrytypes
|
||||
// Test JSON Type Set Data Setup
|
||||
// ============================================================================
|
||||
|
||||
// TestJSONTypeSet returns a map of path->types for testing
|
||||
// This represents the type information available in the test JSON structure
|
||||
// TestJSONTypeSet returns a map of path->types for testing.
|
||||
// This represents the type information available in the test JSON structure.
|
||||
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
|
||||
types := map[string][]JSONDataType{
|
||||
"user.name": {String},
|
||||
"user.permissions": {ArrayString},
|
||||
"user.age": {Int64, String},
|
||||
"user.height": {Float64},
|
||||
"education": {ArrayJSON},
|
||||
"education[].name": {String},
|
||||
"education[].type": {String, Int64},
|
||||
"education[].internal_type": {String},
|
||||
"education[].metadata.location": {String},
|
||||
"education[].parameters": {ArrayFloat64, ArrayDynamic},
|
||||
"education[].duration": {String},
|
||||
"education[].mode": {String},
|
||||
"education[].year": {Int64},
|
||||
"education[].field": {String},
|
||||
"education[].awards": {ArrayDynamic, ArrayJSON},
|
||||
"education[].awards[].name": {String},
|
||||
"education[].awards[].rank": {Int64},
|
||||
"education[].awards[].medal": {String},
|
||||
"education[].awards[].type": {String},
|
||||
"education[].awards[].semester": {Int64},
|
||||
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
|
||||
"education[].awards[].participated[].type": {String},
|
||||
"education[].awards[].participated[].field": {String},
|
||||
"education[].awards[].participated[].project_type": {String},
|
||||
"education[].awards[].participated[].project_name": {String},
|
||||
"education[].awards[].participated[].race_type": {String},
|
||||
"education[].awards[].participated[].team_based": {Bool},
|
||||
"education[].awards[].participated[].team_name": {String},
|
||||
"education[].awards[].participated[].team": {ArrayJSON},
|
||||
"education[].awards[].participated[].members": {ArrayString},
|
||||
"education[].awards[].participated[].team[].name": {String},
|
||||
"education[].awards[].participated[].team[].branch": {String},
|
||||
"education[].awards[].participated[].team[].semester": {Int64},
|
||||
"interests": {ArrayJSON},
|
||||
"interests[].type": {String},
|
||||
"interests[].entities": {ArrayJSON},
|
||||
"interests[].entities.application_date": {String},
|
||||
"interests[].entities[].reviews": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].given_by": {String},
|
||||
"interests[].entities[].reviews[].remarks": {String},
|
||||
"interests[].entities[].reviews[].weight": {Float64},
|
||||
"interests[].entities[].reviews[].passed": {Bool},
|
||||
"interests[].entities[].reviews[].type": {String},
|
||||
"interests[].entities[].reviews[].analysis_type": {Int64},
|
||||
"interests[].entities[].reviews[].entries": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].subject": {String},
|
||||
"interests[].entities[].reviews[].entries[].status": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].company": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].experience": {Int64},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].unit": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].duration": {Int64, Float64},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
|
||||
"message": {String},
|
||||
"tags": {ArrayString},
|
||||
|
||||
// ── user (primitives) ─────────────────────────────────────────────
|
||||
"user.name": {String},
|
||||
"user.permissions": {ArrayString},
|
||||
"user.age": {Int64, String}, // Int64/String ambiguity
|
||||
"user.height": {Float64},
|
||||
"user.active": {Bool}, // Bool — not IndexSupported
|
||||
|
||||
// Deeper non-array nesting (a.b.c — no array hops)
|
||||
"user.address.zip": {Int64},
|
||||
|
||||
// ── education[] ───────────────────────────────────────────────────
|
||||
// Pattern: x[].y
|
||||
"education": {ArrayJSON},
|
||||
"education[].name": {String},
|
||||
"education[].type": {String, Int64},
|
||||
"education[].year": {Int64},
|
||||
"education[].scores": {ArrayInt64},
|
||||
"education[].parameters": {ArrayFloat64, ArrayDynamic},
|
||||
|
||||
// Pattern: x[].y[]
|
||||
"education[].awards": {ArrayDynamic, ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z
|
||||
"education[].awards[].name": {String},
|
||||
"education[].awards[].type": {String},
|
||||
"education[].awards[].semester": {Int64},
|
||||
|
||||
// Pattern: x[].y[].z[]
|
||||
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w
|
||||
"education[].awards[].participated[].members": {ArrayString},
|
||||
|
||||
// Pattern: x[].y[].z[].w[]
|
||||
"education[].awards[].participated[].team": {ArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w[].v
|
||||
"education[].awards[].participated[].team[].branch": {String},
|
||||
|
||||
// ── interests[] ───────────────────────────────────────────────────
|
||||
"interests": {ArrayJSON},
|
||||
"interests[].entities": {ArrayJSON},
|
||||
"interests[].entities[].reviews": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
|
||||
|
||||
// ── http-events[] ─────────────────────────────────────────────────
|
||||
"http-events": {ArrayJSON},
|
||||
"http-events[].request-info.host": {String},
|
||||
|
||||
// ── top-level primitives ──────────────────────────────────────────
|
||||
"message": {String},
|
||||
"http-status": {Int64, String}, // hyphen in root key, ambiguous
|
||||
|
||||
// ── top-level nested objects (no array hops) ───────────────────────
|
||||
"response.time-taken": {Float64}, // hyphen inside nested key
|
||||
}
|
||||
|
||||
return types, nil
|
||||
}
|
||||
|
||||
// TestIndexedPathEntry is a path + JSON type pair representing a field
|
||||
// backed by a ClickHouse skip index in the test data.
|
||||
//
|
||||
// Only non-array paths with IndexSupported types (String, Int64, Float64)
|
||||
// are valid entries — arrays and Bool cannot carry a skip index.
|
||||
//
|
||||
// The ColumnExpression for each entry is computed at test-setup time from
|
||||
// the access plan, since it depends on the column name (e.g. body_v2)
|
||||
// which is unknown to this package.
|
||||
type TestIndexedPathEntry struct {
|
||||
Path string
|
||||
Type JSONDataType
|
||||
}
|
||||
|
||||
// TestIndexedPaths lists path+type pairs from TestJSONTypeSet that are
|
||||
// backed by a JSON data type index. Test setup uses this to populate
|
||||
// key.Indexes after calling SetJSONAccessPlan.
|
||||
//
|
||||
// Intentionally excluded:
|
||||
// - user.active → Bool, IndexSupported=false
|
||||
var TestIndexedPaths = []TestIndexedPathEntry{
|
||||
// user primitives
|
||||
{Path: "user.name", Type: String},
|
||||
|
||||
// user.address — deeper non-array nesting
|
||||
{Path: "user.address.zip", Type: Int64},
|
||||
|
||||
// root-level with special characters
|
||||
{Path: "http-status", Type: Int64},
|
||||
{Path: "http-status", Type: String},
|
||||
|
||||
// root-level nested objects (no array hops)
|
||||
{Path: "response.time-taken", Type: Float64},
|
||||
}
|
||||
|
||||
@@ -654,6 +654,19 @@ def get_oidc_domain(signoz: types.SigNoz, admin_token: str) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dict:
|
||||
"""Helper to get a user by email."""
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
return next(
|
||||
(user for user in response.json()["data"] if user["email"] == email),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def perform_oidc_login(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
idp: types.TestContainerIDP,
|
||||
|
||||
@@ -3,9 +3,6 @@ import os
|
||||
from typing import Any
|
||||
|
||||
import isodate
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
|
||||
|
||||
# parses the given timestamp string from ISO format to datetime.datetime
|
||||
@@ -34,104 +31,3 @@ def parse_duration(duration: Any) -> datetime.timedelta:
|
||||
def get_testdata_file_path(file: str) -> str:
|
||||
testdata_dir = os.path.join(os.path.dirname(__file__), "..", "testdata")
|
||||
return os.path.join(testdata_dir, file)
|
||||
|
||||
|
||||
def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dict:
|
||||
"""Helper to get a user by email."""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"} if admin_token else {}
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
timeout=2,
|
||||
headers=headers,
|
||||
)
|
||||
return next(
|
||||
(user for user in response.json()["data"] if user["email"] == email),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def get_user_role_names(signoz: types.SigNoz, admin_token: str, user_id: str) -> list:
|
||||
"""Helper to get the user roles by user ID"""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"} if admin_token else {}
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
timeout=2,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
roles = response.json()["data"]
|
||||
if not roles:
|
||||
return []
|
||||
|
||||
return [role["name"] for role in roles]
|
||||
|
||||
|
||||
def get_user_roles(signoz: types.SigNoz, admin_token: str, user_id: str) -> list:
|
||||
"""Helper to get the user roles (full objects) by user ID"""
|
||||
headers = {"Authorization": f"Bearer {admin_token}"} if admin_token else {}
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
timeout=2,
|
||||
headers=headers,
|
||||
)
|
||||
return response.json()["data"] or []
|
||||
|
||||
|
||||
def add_user_role(
|
||||
signoz: types.SigNoz, admin_token: str, user_id: str, role_name: str
|
||||
) -> None:
|
||||
"""Helper to add a role to a user via POST /api/v2/users/{id}/roles"""
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
|
||||
json={"name": role_name},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert (
|
||||
response.status_code == 200
|
||||
), f"failed to add role {role_name}: {response.text}"
|
||||
|
||||
|
||||
def remove_user_role_by_name(
|
||||
signoz: types.SigNoz, admin_token: str, user_id: str, role_name: str
|
||||
) -> None:
|
||||
"""Helper to remove a role from a user by role name"""
|
||||
roles = get_user_roles(signoz, admin_token, user_id)
|
||||
role_id = next((r["id"] for r in roles if r["name"] == role_name), None)
|
||||
assert role_id is not None, f"role {role_name} not found for user {user_id}"
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v2/users/{user_id}/roles/{role_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert (
|
||||
response.status_code == 204
|
||||
), f"failed to remove role {role_name}: {response.text}"
|
||||
|
||||
|
||||
def set_user_roles(
|
||||
signoz: types.SigNoz, admin_token: str, user_id: str, desired_role_names: list
|
||||
) -> None:
|
||||
"""Helper to set exact roles for a user using POST/DELETE endpoints"""
|
||||
current_roles = get_user_roles(signoz, admin_token, user_id)
|
||||
current_names = {r["name"] for r in current_roles}
|
||||
desired_names = set(desired_role_names)
|
||||
|
||||
# Remove roles not in desired set
|
||||
for role in current_roles:
|
||||
if role["name"] not in desired_names:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v2/users/{user_id}/roles/{role['id']}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Add roles not in current set
|
||||
for name in desired_names:
|
||||
if name not in current_names:
|
||||
add_user_role(signoz, admin_token, user_id, name)
|
||||
|
||||
@@ -12,8 +12,11 @@ from fixtures.auth import (
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
)
|
||||
from fixtures.idputils import get_saml_domain, perform_saml_login
|
||||
from fixtures.utils import get_user_by_email, get_user_role_names
|
||||
from fixtures.idputils import (
|
||||
get_saml_domain,
|
||||
get_user_by_email,
|
||||
perform_saml_login,
|
||||
)
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
|
||||
|
||||
|
||||
@@ -128,12 +131,26 @@ def test_saml_authn(
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Assert that the user was created in signoz.
|
||||
found_user = get_user_by_email(signoz, admin_token, "viewer@saml.integration.test")
|
||||
assert found_user is not None
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Confirm role
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(
|
||||
user
|
||||
for user in user_response
|
||||
if user["email"] == "viewer@saml.integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_idp_initiated_saml_authn(
|
||||
@@ -165,14 +182,26 @@ def test_idp_initiated_saml_authn(
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Assert that the user was created in signoz.
|
||||
found_user = get_user_by_email(
|
||||
signoz, admin_token, "viewer.idp.initiated@saml.integration.test"
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert found_user is not None
|
||||
|
||||
# Confirm role
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(
|
||||
user
|
||||
for user in user_response
|
||||
if user["email"] == "viewer.idp.initiated@saml.integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_update_domain_with_group_mappings(
|
||||
@@ -242,8 +271,7 @@ def test_saml_role_mapping_single_group_admin(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_saml_role_mapping_single_group_editor(
|
||||
@@ -269,8 +297,7 @@ def test_saml_role_mapping_single_group_editor(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
|
||||
def test_saml_role_mapping_multiple_groups_highest_wins(
|
||||
@@ -300,8 +327,7 @@ def test_saml_role_mapping_multiple_groups_highest_wins(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
|
||||
def test_saml_role_mapping_explicit_viewer_group(
|
||||
@@ -328,8 +354,7 @@ def test_saml_role_mapping_explicit_viewer_group(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_role_mapping_unmapped_group_uses_default(
|
||||
@@ -355,8 +380,7 @@ def test_saml_role_mapping_unmapped_group_uses_default(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_update_domain_with_use_role_claim(
|
||||
@@ -433,8 +457,7 @@ def test_saml_role_mapping_role_claim_takes_precedence(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_saml_role_mapping_invalid_role_claim_fallback(
|
||||
@@ -464,8 +487,7 @@ def test_saml_role_mapping_invalid_role_claim_fallback(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
|
||||
def test_saml_role_mapping_case_insensitive(
|
||||
@@ -495,8 +517,7 @@ def test_saml_role_mapping_case_insensitive(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_saml_name_mapping(
|
||||
@@ -524,8 +545,7 @@ def test_saml_name_mapping(
|
||||
assert (
|
||||
found_user["displayName"] == "Jane"
|
||||
) # We are only mapping the first name here
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_empty_name_fallback(
|
||||
@@ -550,8 +570,7 @@ def test_saml_empty_name_fallback(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_sso_login_activates_pending_invite_user(
|
||||
@@ -594,8 +613,7 @@ def test_saml_sso_login_activates_pending_invite_user(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
assert found_user is not None
|
||||
assert found_user["status"] == "active"
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_sso_deleted_user_gets_new_user_on_login(
|
||||
@@ -662,7 +680,7 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
|
||||
|
||||
# Verify a NEW active user was auto-provisioned via SSO
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
@@ -676,7 +694,4 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
|
||||
)
|
||||
assert found_user is not None
|
||||
assert found_user["status"] == "active"
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert (
|
||||
"signoz-viewer" in found_user_role_names
|
||||
) # default role from SSO domain config
|
||||
assert found_user["role"] == "VIEWER" # default role from SSO domain config
|
||||
|
||||
@@ -11,8 +11,11 @@ from fixtures.auth import (
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
)
|
||||
from fixtures.idputils import get_oidc_domain, perform_oidc_login
|
||||
from fixtures.utils import get_user_by_email, get_user_role_names
|
||||
from fixtures.idputils import (
|
||||
get_oidc_domain,
|
||||
get_user_by_email,
|
||||
perform_oidc_login,
|
||||
)
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
|
||||
|
||||
|
||||
@@ -109,12 +112,26 @@ def test_oidc_authn(
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Assert that the user was created in signoz.
|
||||
found_user = get_user_by_email(signoz, admin_token, "viewer@oidc.integration.test")
|
||||
assert found_user is not None
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Confirm role
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(
|
||||
user
|
||||
for user in user_response
|
||||
if user["email"] == "viewer@oidc.integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_oidc_update_domain_with_group_mappings(
|
||||
@@ -191,8 +208,7 @@ def test_oidc_role_mapping_single_group_admin(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_oidc_role_mapping_single_group_editor(
|
||||
@@ -218,8 +234,7 @@ def test_oidc_role_mapping_single_group_editor(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
|
||||
def test_oidc_role_mapping_multiple_groups_highest_wins(
|
||||
@@ -249,8 +264,7 @@ def test_oidc_role_mapping_multiple_groups_highest_wins(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_oidc_role_mapping_explicit_viewer_group(
|
||||
@@ -277,8 +291,7 @@ def test_oidc_role_mapping_explicit_viewer_group(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_oidc_role_mapping_unmapped_group_uses_default(
|
||||
@@ -304,8 +317,7 @@ def test_oidc_role_mapping_unmapped_group_uses_default(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_oidc_update_domain_with_use_role_claim(
|
||||
@@ -385,8 +397,7 @@ def test_oidc_role_mapping_role_claim_takes_precedence(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_oidc_role_mapping_invalid_role_claim_fallback(
|
||||
@@ -418,8 +429,7 @@ def test_oidc_role_mapping_invalid_role_claim_fallback(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
|
||||
def test_oidc_role_mapping_case_insensitive(
|
||||
@@ -449,8 +459,7 @@ def test_oidc_role_mapping_case_insensitive(
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
|
||||
def test_oidc_name_mapping(
|
||||
@@ -473,13 +482,20 @@ def test_oidc_name_mapping(
|
||||
)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
found_user = next((u for u in users if u["email"] == email), None)
|
||||
|
||||
assert found_user is not None
|
||||
# Keycloak concatenates firstName + lastName into "name" claim
|
||||
assert found_user["displayName"] == "John Doe"
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names # Default role
|
||||
assert found_user["role"] == "VIEWER" # Default role
|
||||
|
||||
|
||||
def test_oidc_empty_name_uses_fallback(
|
||||
@@ -502,12 +518,19 @@ def test_oidc_empty_name_uses_fallback(
|
||||
)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
found_user = next((u for u in users if u["email"] == email), None)
|
||||
|
||||
# User should still be created even with empty name
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
# Note: displayName may be empty - this is a known limitation
|
||||
|
||||
|
||||
@@ -547,9 +570,16 @@ def test_oidc_sso_login_activates_pending_invite_user(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
|
||||
# User should be active with VIEWER role from SSO
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
# User should be active with ADMIN role from invite, not VIEWER from SSO
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
found_user = next(
|
||||
(user for user in response.json()["data"] if user["email"] == email),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
assert found_user["status"] == "active"
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-viewer" in found_user_role_names
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
@@ -5,7 +5,6 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.utils import get_user_by_email, get_user_role_names
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -75,10 +74,31 @@ def test_register(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
|
||||
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
found_user = get_user_by_email(signoz, admin_token, "admin@integration.test")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(user for user in user_response if user["email"] == "admin@integration.test"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-admin" in found_user_role_names
|
||||
assert found_user["role"] == "ADMIN"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/user/{found_user["id"]}"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["role"] == "ADMIN"
|
||||
|
||||
|
||||
def test_invite_and_register(
|
||||
@@ -100,11 +120,21 @@ def test_invite_and_register(
|
||||
assert invited_user["role"] == "EDITOR"
|
||||
|
||||
# Verify the user user appears in the users list but as pending_invite status
|
||||
found_user = get_user_by_email(signoz, admin_token, "editor@integration.test")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(user for user in user_response if user["email"] == "editor@integration.test"),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
assert found_user["status"] == "pending_invite"
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
@@ -122,7 +152,7 @@ def test_invite_and_register(
|
||||
|
||||
# Verify that an admin endpoint cannot be called by the editor user
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
)
|
||||
@@ -130,12 +160,24 @@ def test_invite_and_register(
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# Verify that the editor user status has been updated to ACTIVE
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
found_user = get_user_by_email(signoz, admin_token, "editor@integration.test")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={
|
||||
"Authorization": f"Bearer {get_token("admin@integration.test", "password123Z$")}"
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(user for user in user_response if user["email"] == "editor@integration.test"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
assert found_user["role"] == "EDITOR"
|
||||
assert found_user["displayName"] == "editor"
|
||||
assert found_user["email"] == "editor@integration.test"
|
||||
assert found_user["status"] == "active"
|
||||
@@ -179,7 +221,25 @@ def test_self_access(
|
||||
) -> None:
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
found_user = get_user_by_email(signoz, admin_token, "editor@integration.test")
|
||||
assert found_user is not None
|
||||
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
|
||||
assert "signoz-editor" in found_user_role_names
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(user for user in user_response if user["email"] == "editor@integration.test"),
|
||||
None,
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/user/{found_user['id']}"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["role"] == "EDITOR"
|
||||
|
||||
@@ -26,7 +26,7 @@ def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
|
||||
assert "token" in pat_response["data"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
|
||||
)
|
||||
@@ -85,7 +85,7 @@ def test_api_key_role(
|
||||
assert "token" in pat_response["data"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
|
||||
)
|
||||
@@ -109,7 +109,7 @@ def test_api_key_role(
|
||||
assert "token" in pat_response["data"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ from sqlalchemy import sql
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.utils import get_user_by_email
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -36,10 +35,23 @@ def test_change_password(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Get the user id
|
||||
found_user = get_user_by_email(
|
||||
signoz, admin_token, "admin+password@integration.test"
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(
|
||||
user
|
||||
for user in user_response
|
||||
if user["email"] == "admin+password@integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
|
||||
# Try logging in with the password
|
||||
token = get_token("admin+password@integration.test", "password123Z$")
|
||||
@@ -88,10 +100,23 @@ def test_reset_password(
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
# Get the user id for admin+password@integration.test
|
||||
found_user = get_user_by_email(
|
||||
signoz, admin_token, "admin+password@integration.test"
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(
|
||||
user
|
||||
for user in user_response
|
||||
if user["email"] == "admin+password@integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
@@ -133,10 +158,23 @@ def test_reset_password_with_no_password(
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
# Get the user id for admin+password@integration.test
|
||||
found_user = get_user_by_email(
|
||||
signoz, admin_token, "admin+password@integration.test"
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(
|
||||
user
|
||||
for user in user_response
|
||||
if user["email"] == "admin+password@integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
result = conn.execute(
|
||||
@@ -267,7 +305,17 @@ def test_forgot_password_creates_reset_token(
|
||||
|
||||
# Verify reset password token was created by querying the database
|
||||
# First, get the user ID
|
||||
found_user = get_user_by_email(signoz, admin_token, "forgot@integration.test")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(user for user in user_response if user["email"] == "forgot@integration.test"),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
|
||||
reset_token = None
|
||||
@@ -323,7 +371,17 @@ def test_reset_password_with_expired_token(
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
# Get user ID for the forgot@integration.test user
|
||||
found_user = get_user_by_email(signoz, admin_token, "forgot@integration.test")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
user_response = response.json()["data"]
|
||||
found_user = next(
|
||||
(user for user in user_response if user["email"] == "forgot@integration.test"),
|
||||
None,
|
||||
)
|
||||
assert found_user is not None
|
||||
|
||||
# Get org ID
|
||||
|
||||
@@ -4,12 +4,6 @@ from typing import Callable, Tuple
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.utils import (
|
||||
add_user_role,
|
||||
get_user_role_names,
|
||||
remove_user_role_by_name,
|
||||
set_user_roles,
|
||||
)
|
||||
|
||||
|
||||
def test_change_role(
|
||||
@@ -46,7 +40,7 @@ def test_change_role(
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
@@ -64,13 +58,22 @@ def test_change_role(
|
||||
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# Change the new user's role - add ADMIN, remove VIEWER
|
||||
add_user_role(signoz, admin_token, new_user_id, "signoz-admin")
|
||||
remove_user_role_by_name(signoz, admin_token, new_user_id, "signoz-viewer")
|
||||
# Change the new user's role - move to ADMIN
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/user/{new_user_id}"),
|
||||
json={
|
||||
"displayName": "role change user",
|
||||
"role": "ADMIN",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
# Make some API calls again
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
@@ -103,268 +106,3 @@ def test_change_role(
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
def test_remove_all_roles(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
get_tokens: Callable[[str, str], Tuple[str, str]],
|
||||
):
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
# Create a new user as EDITOR
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": "admin+noroles@integration.test", "role": "EDITOR"},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Activate user via reset password
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Login and get user id
|
||||
new_user_token, new_user_refresh_token = get_tokens(
|
||||
"admin+noroles@integration.test", "password123Z$"
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
new_user_id = response.json()["data"]["id"]
|
||||
|
||||
# Validate the user has the editor role
|
||||
role_names = get_user_role_names(signoz, admin_token, new_user_id)
|
||||
assert role_names is not None
|
||||
assert "signoz-editor" in role_names
|
||||
|
||||
# Remove all roles via DELETE endpoint
|
||||
set_user_roles(signoz, admin_token, new_user_id, [])
|
||||
|
||||
# Validate the user has no roles
|
||||
role_names = get_user_role_names(signoz, admin_token, new_user_id)
|
||||
assert role_names is None or len(role_names) == 0
|
||||
|
||||
# Old token should be invalidated after role change
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
# Token rotation should also fail for a user with no roles
|
||||
# (the session endpoint requires roles to build an identity)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
|
||||
json={
|
||||
"refreshToken": new_user_refresh_token,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert (
|
||||
response.status_code != HTTPStatus.OK
|
||||
), "token rotation should fail for user with no roles"
|
||||
|
||||
|
||||
def test_multiple_roles(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
get_tokens: Callable[[str, str], Tuple[str, str]],
|
||||
):
|
||||
admin_token = get_token("admin@integration.test", "password123Z$")
|
||||
|
||||
# Create a new user as VIEWER
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": "admin+multirole@integration.test", "role": "VIEWER"},
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
invited_user = response.json()["data"]
|
||||
reset_token = invited_user["token"]
|
||||
|
||||
# Activate user via reset password
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Login and get user id
|
||||
new_user_token, new_user_refresh_token = get_tokens(
|
||||
"admin+multirole@integration.test", "password123Z$"
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
new_user_id = response.json()["data"]["id"]
|
||||
|
||||
# Validate user starts with viewer role
|
||||
role_names = get_user_role_names(signoz, admin_token, new_user_id)
|
||||
assert role_names is not None
|
||||
assert role_names == [
|
||||
"signoz-viewer"
|
||||
], f"expected ['signoz-viewer'], got {role_names}"
|
||||
|
||||
# As viewer, admin-only APIs should be forbidden
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# Assign multiple roles: add editor (viewer already assigned)
|
||||
add_user_role(signoz, admin_token, new_user_id, "signoz-editor")
|
||||
|
||||
# Validate user has both roles
|
||||
role_names = get_user_role_names(signoz, admin_token, new_user_id)
|
||||
assert role_names is not None
|
||||
assert sorted(role_names) == [
|
||||
"signoz-editor",
|
||||
"signoz-viewer",
|
||||
], f"expected ['signoz-editor', 'signoz-viewer'], got {sorted(role_names)}"
|
||||
|
||||
# Rotate token to pick up new roles
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
|
||||
json={"refreshToken": new_user_refresh_token},
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rotate_response = response.json()["data"]
|
||||
new_user_token = rotate_response["accessToken"]
|
||||
new_user_refresh_token = rotate_response["refreshToken"]
|
||||
|
||||
# Verify /me includes both roles
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
me_role_names = sorted(
|
||||
ur["role"]["name"] for ur in response.json()["data"]["userRoles"]
|
||||
)
|
||||
assert me_role_names == [
|
||||
"signoz-editor",
|
||||
"signoz-viewer",
|
||||
], f"expected ['signoz-editor', 'signoz-viewer'] in /me, got {me_role_names}"
|
||||
|
||||
# Editor+viewer still cannot access admin-only APIs
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# Add admin role (editor + viewer already assigned)
|
||||
add_user_role(signoz, admin_token, new_user_id, "signoz-admin")
|
||||
|
||||
role_names = get_user_role_names(signoz, admin_token, new_user_id)
|
||||
assert sorted(role_names) == [
|
||||
"signoz-admin",
|
||||
"signoz-editor",
|
||||
"signoz-viewer",
|
||||
], f"expected all three roles, got {sorted(role_names)}"
|
||||
|
||||
# Rotate token to pick up admin role
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
|
||||
json={"refreshToken": new_user_refresh_token},
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rotate_response = response.json()["data"]
|
||||
new_user_token = rotate_response["accessToken"]
|
||||
new_user_refresh_token = rotate_response["refreshToken"]
|
||||
|
||||
# Now with admin role, admin-only APIs should succeed
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
# Reduce back to single viewer role
|
||||
set_user_roles(signoz, admin_token, new_user_id, ["signoz-viewer"])
|
||||
|
||||
role_names = get_user_role_names(signoz, admin_token, new_user_id)
|
||||
assert role_names == [
|
||||
"signoz-viewer"
|
||||
], f"expected ['signoz-viewer'] after reduction, got {role_names}"
|
||||
|
||||
# Rotate token to pick up reduced roles
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
|
||||
json={"refreshToken": new_user_refresh_token},
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rotate_response = response.json()["data"]
|
||||
new_user_token = rotate_response["accessToken"]
|
||||
|
||||
# After reducing to viewer, admin-only APIs should be forbidden again
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {new_user_token}"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_root_user_signoz_admin_assignment(
|
||||
|
||||
# Get the user from the /user/me endpoint and extract the id
|
||||
user_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ def test_user_invite_accept_role_grant(
|
||||
# Login with editor email and password
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
user_me_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
@@ -102,7 +102,7 @@ def test_user_update_role_grant(
|
||||
# Get the editor user's id
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
user_me_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
@@ -120,37 +120,15 @@ def test_user_update_role_grant(
|
||||
roles_data = roles_response.json()["data"]
|
||||
org_id = roles_data[0]["orgId"]
|
||||
|
||||
# Add the viewer role to the user
|
||||
add_response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_id}/roles"),
|
||||
json={"name": "signoz-viewer"},
|
||||
# Update the user's role to viewer
|
||||
update_payload = {"role": "VIEWER"}
|
||||
update_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/user/{editor_id}"),
|
||||
json=update_payload,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert add_response.status_code == HTTPStatus.OK
|
||||
|
||||
# Get the editor role id so we can remove it
|
||||
roles_list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_id}/roles"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert roles_list_response.status_code == HTTPStatus.OK
|
||||
editor_role_id = next(
|
||||
r["id"]
|
||||
for r in roles_list_response.json()["data"]
|
||||
if r["name"] == "signoz-editor"
|
||||
)
|
||||
|
||||
# Remove the editor role
|
||||
remove_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(
|
||||
f"/api/v2/users/{editor_id}/roles/{editor_role_id}"
|
||||
),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert remove_response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert update_response.status_code == HTTPStatus.OK
|
||||
|
||||
# Check that user no longer has the editor role in the db
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
@@ -201,7 +179,7 @@ def test_user_delete_role_revoke(
|
||||
# login with editor to get the user_id and check if user exists
|
||||
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
|
||||
user_me_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
@@ -244,120 +222,3 @@ def test_user_delete_role_revoke(
|
||||
else:
|
||||
_user = f"user:organization/{org_id}/user/{editor_id}"
|
||||
assert row["_user"] != _user
|
||||
|
||||
|
||||
def test_update_my_user(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Invite a viewer user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": "admin+updateme@integration.test", "role": "VIEWER"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
# Activate user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Login as viewer
|
||||
viewer_token = get_token("admin+updateme@integration.test", "password123Z$")
|
||||
|
||||
# Update own display name via PUT /api/v2/users/me
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
json={"displayName": "viewer updated name"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify the update via GET /api/v2/users/me
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["displayName"] == "viewer updated name"
|
||||
|
||||
|
||||
def test_update_user_by_id(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Invite a user to update
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/invite"),
|
||||
json={"email": "admin+updatetest@integration.test", "role": "VIEWER"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
reset_token = response.json()["data"]["token"]
|
||||
|
||||
# Activate user
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
|
||||
json={"password": "password123Z$", "token": reset_token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Get user id
|
||||
user_token = get_token("admin+updatetest@integration.test", "password123Z$")
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
user_id = response.json()["data"]["id"]
|
||||
|
||||
# Admin updates user's display name via PUT /api/v2/users/{id}
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
|
||||
json={"displayName": "renamed user"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Verify via GET /api/v2/users/{id}
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["displayName"] == "renamed user"
|
||||
|
||||
# Self-update should be rejected
|
||||
me_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
admin_id = me_response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
|
||||
json={"displayName": "self update"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
@@ -14,7 +14,7 @@ def test_root_user_created(signoz: types.SigNoz) -> None:
|
||||
The root user service reconciles asynchronously after startup.
|
||||
|
||||
Phase 1: Poll /api/v1/version until setupCompleted=true.
|
||||
Phase 2: Poll /api/v2/users until it returns 200, confirming the root
|
||||
Phase 2: Poll /api/v1/user until it returns 200, confirming the root
|
||||
user actually exists and the impersonation provider works.
|
||||
"""
|
||||
# Phase 1: wait for setupCompleted
|
||||
@@ -39,13 +39,13 @@ def test_root_user_created(signoz: types.SigNoz) -> None:
|
||||
# Phase 2: wait for root user to be fully resolved
|
||||
for attempt in range(15):
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
)
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
return
|
||||
logger.info(
|
||||
"Attempt %s: /api/v2/users returned %s, retrying ...",
|
||||
"Attempt %s: /api/v1/user returned %s, retrying ...",
|
||||
attempt + 1,
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.utils import get_user_role_names
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -33,7 +32,7 @@ def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None:
|
||||
Listing users is an admin-only endpoint.
|
||||
"""
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
@@ -47,5 +46,4 @@ def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None:
|
||||
None,
|
||||
)
|
||||
assert root_user is not None
|
||||
root_user_role_names = get_user_role_names(signoz, None, root_user["id"])
|
||||
assert "signoz-admin" in root_user_role_names
|
||||
assert root_user["role"] == "ADMIN"
|
||||
|
||||
Reference in New Issue
Block a user