mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-21 21:50:36 +01:00
Compare commits
16 Commits
feat/rules
...
fix/log-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee23a458d5 | ||
|
|
cc45c1bef1 | ||
|
|
e70b3a0b52 | ||
|
|
f514af469e | ||
|
|
806853798d | ||
|
|
f002b29685 | ||
|
|
a5d4ef4498 | ||
|
|
967289ebc6 | ||
|
|
bf0130a983 | ||
|
|
9d4349c9d1 | ||
|
|
d095645d61 | ||
|
|
429fca62be | ||
|
|
20518a98e9 | ||
|
|
0b7c266b09 | ||
|
|
8014c065e8 | ||
|
|
c2c2552cc6 |
@@ -8558,6 +8558,7 @@ components:
|
||||
- resource
|
||||
- attribute
|
||||
- body
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesFieldDataType:
|
||||
enum:
|
||||
@@ -8566,6 +8567,7 @@ components:
|
||||
- float64
|
||||
- int64
|
||||
- number
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesGettableFieldKeys:
|
||||
properties:
|
||||
@@ -8597,6 +8599,7 @@ components:
|
||||
- traces
|
||||
- logs
|
||||
- metrics
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesSource:
|
||||
enum:
|
||||
|
||||
@@ -223,17 +223,12 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
|
||||
@@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
mock := mockStore.Mock()
|
||||
|
||||
// Mock the fingerprint query (for Prometheus label matching)
|
||||
// args: $1=metric_name (the __name__ matcher maps onto the column)
|
||||
mock.ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// Mock the samples query (for Prometheus metric data)
|
||||
// args: metric_name IN (discovered names), subquery metric_name, start, end
|
||||
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration",
|
||||
"LLM_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | LLM Observability Attribute Mapping"
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
}
|
||||
@@ -134,8 +134,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
|
||||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
|
||||
(pathname.startsWith(`${ROUTES.AI_OBSERVABILITY_BASE}/`) ||
|
||||
pathname === ROUTES.AI_OBSERVABILITY_BASE) &&
|
||||
!isAIObservabilityEnabled
|
||||
) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
|
||||
@@ -514,24 +514,24 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
key: 'AI_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
key: 'AI_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3479,6 +3479,7 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
resource = 'resource',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
'' = '',
|
||||
}
|
||||
export enum TelemetrytypesFieldDataTypeDTO {
|
||||
string = 'string',
|
||||
@@ -3486,11 +3487,13 @@ export enum TelemetrytypesFieldDataTypeDTO {
|
||||
float64 = 'float64',
|
||||
int64 = 'int64',
|
||||
number = 'number',
|
||||
'' = '',
|
||||
}
|
||||
export enum TelemetrytypesSignalDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
'' = '',
|
||||
}
|
||||
export interface Querybuildertypesv5GroupByKeyDTO {
|
||||
/**
|
||||
|
||||
@@ -380,4 +380,88 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw logs body: extract lone `message` field', () => {
|
||||
function makeRawResult(
|
||||
rows: Array<{ timestamp: string; data: Record<string, any> }>,
|
||||
type: 'raw' | 'trace' = 'raw',
|
||||
): ReturnType<typeof convertV5ResponseToLegacy> {
|
||||
const v5Data = {
|
||||
type,
|
||||
data: { results: [{ queryName: 'A', rows }] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
|
||||
const params = makeBaseParams(type as RequestType, [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: type === 'trace' ? 'traces' : 'logs',
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
|
||||
makeBaseSuccess({ data: v5Data }, params);
|
||||
|
||||
return convertV5ResponseToLegacy(input, { A: 'A' }, false);
|
||||
}
|
||||
|
||||
it('unwraps body when it is an object with only a message field', () => {
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: 'hello' } } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe('hello');
|
||||
});
|
||||
|
||||
it('leaves body unchanged when the object has keys besides message', () => {
|
||||
const body = { message: 'hello', level: 'INFO' };
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toStrictEqual(
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a string body unchanged (use_json_body off)', () => {
|
||||
const result = makeRawResult([
|
||||
{
|
||||
timestamp: '2026-07-21T00:00:00Z',
|
||||
data: { body: '{"message":"hello"}' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
|
||||
'{"message":"hello"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('stringifies the nested object when message is an object', () => {
|
||||
const nested = { a: 1, b: 2 };
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: nested } } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
|
||||
JSON.stringify(nested),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a body key to rows without a body (traces)', () => {
|
||||
const result = makeRawResult(
|
||||
[{ timestamp: '2026-07-21T00:00:00Z', data: { name: 'span-1' } }],
|
||||
'trace',
|
||||
);
|
||||
|
||||
const data = (result.payload.data.result[0].list?.[0] as any)?.data ?? {};
|
||||
expect('body' in data).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,6 +273,19 @@ function convertScalarWithFormatForWeb(
|
||||
});
|
||||
}
|
||||
|
||||
function extractOnlyMessageBody(body: unknown): unknown {
|
||||
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
|
||||
if (isJsonBody) {
|
||||
const keys = Object.keys(body);
|
||||
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
|
||||
if (hasOnlyMessageKey) {
|
||||
const { message } = body as { message: unknown };
|
||||
return typeof message === 'string' ? message : JSON.stringify(message);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 RawData to legacy format
|
||||
*/
|
||||
@@ -285,14 +298,22 @@ function convertRawData(
|
||||
queryName: rawData.queryName,
|
||||
legend: legendMap[rawData.queryName] || rawData.queryName,
|
||||
series: null,
|
||||
list: rawData.rows?.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
data: {
|
||||
list: rawData.rows?.map((row) => {
|
||||
const data = {
|
||||
// Map raw data to ILog structure - spread row.data first to include all properties
|
||||
...row.data,
|
||||
date: row.timestamp,
|
||||
} as any,
|
||||
})),
|
||||
} as any;
|
||||
|
||||
if ('body' in row.data) {
|
||||
data.body = extractOnlyMessageBody(row.data.body);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
nextCursor: rawData.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
import ShareURLModal from './ShareURLModal';
|
||||
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
|
||||
|
||||
import './HeaderRightSection.styles.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -29,12 +29,15 @@ interface HeaderRightSectionProps {
|
||||
enableAnnouncements: boolean;
|
||||
enableShare: boolean;
|
||||
enableFeedback: boolean;
|
||||
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
|
||||
shareModalExtraOption?: ShareURLExtraOption;
|
||||
}
|
||||
|
||||
function HeaderRightSection({
|
||||
enableAnnouncements,
|
||||
enableShare,
|
||||
enableFeedback,
|
||||
shareModalExtraOption,
|
||||
}: HeaderRightSectionProps): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -185,7 +188,7 @@ function HeaderRightSection({
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<ShareURLModal />}
|
||||
content={<ShareURLModal extraOption={shareModalExtraOption} />}
|
||||
open={openShareURLModal}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
|
||||
@@ -24,7 +24,22 @@ const routesToBeSharedWithTime = [
|
||||
ROUTES.METER_EXPLORER,
|
||||
];
|
||||
|
||||
function ShareURLModal(): JSX.Element {
|
||||
/**
|
||||
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
|
||||
* "Include variables"). When enabled, `apply` mutates the URL params that go into
|
||||
* the shared link. Keeps this shared modal generic — the page owns what it adds.
|
||||
*/
|
||||
export interface ShareURLExtraOption {
|
||||
label: string;
|
||||
defaultEnabled?: boolean;
|
||||
apply: (params: URLSearchParams) => void;
|
||||
}
|
||||
|
||||
interface ShareURLModalProps {
|
||||
extraOption?: ShareURLExtraOption;
|
||||
}
|
||||
|
||||
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
@@ -34,6 +49,9 @@ function ShareURLModal(): JSX.Element {
|
||||
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
|
||||
selectedTime !== 'custom',
|
||||
);
|
||||
const [enableExtraOption, setEnableExtraOption] = useState(
|
||||
extraOption?.defaultEnabled ?? false,
|
||||
);
|
||||
|
||||
const startTime = urlQuery.get(QueryParams.startTime);
|
||||
const endTime = urlQuery.get(QueryParams.endTime);
|
||||
@@ -93,6 +111,11 @@ function ShareURLModal(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
if (extraOption && enableExtraOption) {
|
||||
extraOption.apply(urlQuery);
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
};
|
||||
|
||||
@@ -143,6 +166,20 @@ function ShareURLModal(): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
|
||||
{extraOption && (
|
||||
<div className="absolute-relative-time-toggler-container">
|
||||
<Typography.Text className="absolute-relative-time-toggler-label">
|
||||
{extraOption.label}
|
||||
</Typography.Text>
|
||||
<div className="absolute-relative-time-toggler">
|
||||
<Switch
|
||||
value={enableExtraOption}
|
||||
onChange={(): void => setEnableExtraOption((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="share-link">
|
||||
<div className="url-share-container">
|
||||
<div className="url-share-container-header">
|
||||
|
||||
@@ -247,11 +247,6 @@
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
|
||||
@@ -293,11 +293,6 @@ $max-recents-shown: 5;
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
|
||||
@@ -89,10 +89,10 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
|
||||
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
|
||||
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: '/ai-observability/attribute-mapping',
|
||||
AI_OBSERVABILITY_BASE: '/ai-observability',
|
||||
AI_OBSERVABILITY_OVERVIEW: '/ai-observability/overview',
|
||||
AI_OBSERVABILITY_CONFIGURATION: '/ai-observability/configuration',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -9,6 +9,4 @@
|
||||
:global([class*='dashboardPageHeader']) {
|
||||
display: none;
|
||||
}
|
||||
// remove margin added by the hidden header to avoid extra whitespace at the top of the page
|
||||
margin-top: calc(var(--spacing-8) * -1);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
|
||||
// The drawer body — children render inside [data-slot='drawer-description']
|
||||
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
|
||||
@@ -27,6 +27,8 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
.patternBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
@@ -18,14 +17,14 @@
|
||||
.patternChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-3);
|
||||
min-height: 28px;
|
||||
gap: var(--spacing-0) var(--spacing-3);
|
||||
}
|
||||
|
||||
.patternChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.patternChipRemove {
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
border: 1px solid transparent;
|
||||
background: var(--l3-background);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
// Include padding + border in the 100% width so the card fits inside
|
||||
@@ -67,16 +66,11 @@
|
||||
|
||||
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
|
||||
// Use :has() to highlight the wrapper card when its inner button is checked.
|
||||
&.sourceRadioAuto:has(button[data-state='checked']) {
|
||||
&:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
&.sourceRadioOverride:has(button[data-state='checked']) {
|
||||
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ function SourceSelector({
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="auto"
|
||||
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
|
||||
containerClassName={styles.sourceRadio}
|
||||
testId="drawer-source-auto"
|
||||
disabled={disableAuto}
|
||||
>
|
||||
@@ -73,7 +73,7 @@ function SourceSelector({
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem
|
||||
value="override"
|
||||
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
|
||||
containerClassName={styles.sourceRadio}
|
||||
testId="drawer-source-override"
|
||||
>
|
||||
<div className={styles.sourceRadioTitle}>User override</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
|
||||
color: var(--bg-amber-300, var(--bg-amber-400));
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.bannerIcon {
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('LLMObservability (integration)', () => {
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
@@ -55,32 +55,32 @@ describe('LLMObservability (integration)', () => {
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the attribute mapping route when that tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the attribute mapping page on the attribute mapping route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -91,7 +91,7 @@ describe('LLMObservability (integration)', () => {
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
initialRoute: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -8,9 +8,9 @@ import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabili
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
|
||||
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
|
||||
const OVERVIEW_KEY = ROUTES.AI_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.AI_OBSERVABILITY_CONFIGURATION;
|
||||
const ATTRIBUTE_MAPPING_KEY = ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
.ant-table-cell {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attribute-name {
|
||||
@@ -33,12 +34,12 @@
|
||||
.attribute-pin {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
padding: 14px 8px 8px;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
|
||||
.log-attribute-pin {
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
.ant-tree-node-content-wrapper {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-tree-title {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5262,6 +5262,24 @@ const onboardingConfigWithLinks = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'temporal-cloud-metrics',
|
||||
label: 'Temporal Cloud Metrics',
|
||||
imgUrl: temporalUrl,
|
||||
tags: ['metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'metrics',
|
||||
'integrations',
|
||||
'temporal',
|
||||
'temporal cloud',
|
||||
'temporal cloud metrics',
|
||||
'temporal metrics',
|
||||
'openmetrics',
|
||||
'prometheus',
|
||||
],
|
||||
link: '/docs/integrations/temporal-cloud-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'temporal',
|
||||
label: 'Temporal',
|
||||
@@ -5273,9 +5291,6 @@ const onboardingConfigWithLinks = [
|
||||
'application performance monitoring',
|
||||
'integrations',
|
||||
'temporal',
|
||||
'temporal cloud',
|
||||
'temporal logs',
|
||||
'temporal metrics',
|
||||
'temporal traces',
|
||||
'traces',
|
||||
'tracing',
|
||||
@@ -5284,12 +5299,6 @@ const onboardingConfigWithLinks = [
|
||||
desc: 'What are you using ?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'temporal-cloud',
|
||||
label: 'Cloud Metrics',
|
||||
imgUrl: temporalUrl,
|
||||
link: '/docs/integrations/temporal-cloud-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'temporal-golang',
|
||||
label: 'Go',
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -39,6 +39,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
signalSource,
|
||||
setAttributeKeys,
|
||||
}: AgregatorFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
|
||||
|
||||
@@ -289,7 +290,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
placeholder={getPlaceholder()}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { categoryToSupport } from './config';
|
||||
import { selectStyles } from './styles';
|
||||
@@ -13,6 +13,7 @@ function BuilderUnitsFilter({
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
}: IBuilderUnitsFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
|
||||
|
||||
const selectedValue = yAxisUnit || currentQuery?.unit;
|
||||
@@ -36,7 +37,7 @@ function BuilderUnitsFilter({
|
||||
Y-axis unit
|
||||
</Typography.Text>
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
style={selectStyles}
|
||||
onChange={onChangeHandler}
|
||||
value={selectedValue}
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
} from 'lib/query/transformQueryBuilderData';
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../../utils';
|
||||
import { HavingFilterProps, HavingTagRenderProps } from './types';
|
||||
|
||||
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = formula;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [localValues, setLocalValues] = useState<string[]>([]);
|
||||
@@ -171,7 +172,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearch/config';
|
||||
import { OrderByProps } from './types';
|
||||
@@ -13,6 +13,7 @@ function OrderByFilter({
|
||||
onChange,
|
||||
query,
|
||||
}: OrderByProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
createOptions,
|
||||
@@ -64,7 +65,7 @@ function OrderByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -33,6 +33,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
disabled,
|
||||
signalSource,
|
||||
}: GroupByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [optionsData, setOptionsData] = useState<
|
||||
@@ -174,7 +175,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../utils';
|
||||
// ** Types
|
||||
@@ -27,6 +27,7 @@ export function HavingFilter({
|
||||
query,
|
||||
onChange,
|
||||
}: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = query;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
|
||||
@@ -231,7 +232,7 @@ export function HavingFilter({
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -85,6 +85,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
signalSource,
|
||||
'data-testid': dataTestId,
|
||||
}: MetricNameSelectorProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const currentMetricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
@@ -272,7 +273,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
return (
|
||||
<AutoComplete
|
||||
className="metric-name-selector"
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { OrderByFilterProps } from './OrderByFilter.interfaces';
|
||||
@@ -16,6 +16,7 @@ export function OrderByFilter({
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
@@ -78,7 +79,7 @@ export function OrderByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { FeatureKeys } from '../../../../constants/features';
|
||||
@@ -95,6 +95,7 @@ function QueryBuilderSearch({
|
||||
disableNavigationShortcuts,
|
||||
entity,
|
||||
}: QueryBuilderSearchProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { pathname } = useLocation();
|
||||
const isLogsExplorerPage = useMemo(
|
||||
() => pathname === ROUTES.LOGS_EXPLORER,
|
||||
@@ -397,7 +398,7 @@ function QueryBuilderSearch({
|
||||
<Select
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
getPopupContainer={popupContainer}
|
||||
getPopupContainer={getPopupContainer}
|
||||
transitionName=""
|
||||
choiceTransitionName=""
|
||||
virtual={false}
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
@@ -157,6 +157,8 @@ function QueryBuilderSearchV2(
|
||||
selectProps,
|
||||
} = props;
|
||||
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { handleRunQuery, currentQuery } = useQueryBuilder();
|
||||
@@ -989,7 +991,7 @@ function QueryBuilderSearchV2(
|
||||
{...selectProps}
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
|
||||
{...(hasPopupContainer ? { getPopupContainer } : {})}
|
||||
{...(maxTagCount ? { maxTagCount } : {})}
|
||||
key={queryTags.join('.')}
|
||||
virtual={false}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { isArray } from 'lodash-es';
|
||||
@@ -253,6 +254,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isEditor = user.role === USER_ROLES.EDITOR;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
const aiAssistantActiveConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
@@ -288,15 +290,22 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const shouldShowIntegrationsValue =
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
|
||||
|
||||
const isEnabledForItem = (item: SidebarItem): boolean | undefined => {
|
||||
if (item.key === ROUTES.INTEGRATIONS) {
|
||||
return shouldShowIntegrationsValue;
|
||||
}
|
||||
if (item.key === ROUTES.AI_OBSERVABILITY_OVERVIEW) {
|
||||
return isAIObservabilityEnabled;
|
||||
}
|
||||
return item.isEnabled;
|
||||
};
|
||||
|
||||
return defaultMoreMenuItems.map((item) => ({
|
||||
...item,
|
||||
isPinned: computedPinnedMenuItems.some(
|
||||
(pinned) => pinned.itemKey === item.itemKey,
|
||||
),
|
||||
isEnabled:
|
||||
item.key === ROUTES.INTEGRATIONS
|
||||
? shouldShowIntegrationsValue
|
||||
: item.isEnabled,
|
||||
isEnabled: isEnabledForItem(item),
|
||||
}));
|
||||
}, [
|
||||
computedPinnedMenuItems,
|
||||
@@ -304,6 +313,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
isEnterpriseSelfHostedUser,
|
||||
isAdmin,
|
||||
isEditor,
|
||||
isAIObservabilityEnabled,
|
||||
]);
|
||||
|
||||
// Track if we've done the initial sync (to avoid overwriting user actions during session)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
UserPlus,
|
||||
Users,
|
||||
Binoculars,
|
||||
Brain,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
@@ -288,6 +289,16 @@ export const defaultMoreMenuItems: SidebarItem[] = [
|
||||
isEnabled: true,
|
||||
itemKey: 'external-apis',
|
||||
},
|
||||
{
|
||||
key: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
label: 'AI Observability',
|
||||
icon: <Brain size={16} />,
|
||||
isNew: true,
|
||||
// Gated behind the `enable_ai_observability` feature flag in
|
||||
// SideNav's `computedSecondaryMenuItems`; disabled by default.
|
||||
isEnabled: false,
|
||||
itemKey: 'ai-observability',
|
||||
},
|
||||
{
|
||||
key: ROUTES.METER,
|
||||
label: 'Cost Meter',
|
||||
@@ -553,7 +564,9 @@ export const getUserSettingsDropdownMenuItems = ({
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
/** Mapping of some newly added routes and their corresponding active sidebar menu key */
|
||||
/** Mapping of some newly added routes and their corresponding active sidebar menu key
|
||||
This is used to highlight the correct menu item when the user navigates to a new route
|
||||
**/
|
||||
export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
|
||||
[ROUTES.TRACE]: ROUTES.TRACES_EXPLORER,
|
||||
[ROUTES.TRACE_EXPLORER]: ROUTES.TRACES_EXPLORER,
|
||||
@@ -563,6 +576,7 @@ export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
|
||||
ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
|
||||
[ROUTES.API_MONITORING_BASE]: ROUTES.API_MONITORING,
|
||||
[ROUTES.MESSAGING_QUEUES_BASE]: ROUTES.MESSAGING_QUEUES_OVERVIEW,
|
||||
[ROUTES.AI_OBSERVABILITY_BASE]: ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
// `getActiveMenuKeyFromPath` strips the URL down to its first segment;
|
||||
// `/ai-assistant/<id>` reduces to `/ai-assistant`, which we point back
|
||||
// to the AI Assistant menu item's concrete key.
|
||||
|
||||
@@ -203,9 +203,9 @@ export const routesToSkip = [
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
ROUTES.AI_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.AI_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -126,6 +126,15 @@ export default function UPlotChart({
|
||||
}
|
||||
}, [isDataEmpty, destroyPlot]);
|
||||
|
||||
/**
|
||||
* Destroy the plot on unmount. Without this, uPlot's window-level
|
||||
* `dppxchange` listener keeps the instance (and its whole detached DOM
|
||||
* subtree) alive after the component is gone.
|
||||
*/
|
||||
const destroyPlotRef = useRef(destroyPlot);
|
||||
destroyPlotRef.current = destroyPlot;
|
||||
useEffect(() => (): void => destroyPlotRef.current(), []);
|
||||
|
||||
/**
|
||||
* Handle initialization and prop changes
|
||||
*/
|
||||
|
||||
@@ -327,6 +327,32 @@ describe('UPlotChart', () => {
|
||||
expect(firstInstance.destroy).toHaveBeenCalled();
|
||||
expect(instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('destroys the instance and notifies callbacks on unmount', () => {
|
||||
const plotRef = jest.fn();
|
||||
const onDestroy = jest.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
plotRef={plotRef}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const firstInstance = instances[0];
|
||||
plotRef.mockClear();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
|
||||
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(plotRef).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanGaps data transformation', () => {
|
||||
|
||||
@@ -16,7 +16,13 @@ import { sortBy } from 'lodash-es';
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
/** Telemetry signal — the generated enum (traces / logs / metrics). */
|
||||
export type TelemetrySignal = TelemetrytypesSignalDTO;
|
||||
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
|
||||
// also carries the empty "any" value used on field keys, which is not a valid
|
||||
// query/variable signal, so exclude it here.
|
||||
export type TelemetrySignal =
|
||||
| TelemetrytypesSignalDTO.logs
|
||||
| TelemetrytypesSignalDTO.traces
|
||||
| TelemetrytypesSignalDTO.metrics;
|
||||
|
||||
/**
|
||||
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
|
||||
|
||||
@@ -17,6 +17,9 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
[TelemetrytypesSignalDTO.logs]: 'logs',
|
||||
[TelemetrytypesSignalDTO.traces]: 'traces',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'metrics',
|
||||
// The empty "any" signal only appears on field keys, never on a panel query;
|
||||
// mapped for exhaustiveness.
|
||||
[TelemetrytypesSignalDTO['']]: '',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { ColorPicker } from 'antd';
|
||||
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
@@ -11,11 +12,11 @@ interface ThresholdColorSelectProps {
|
||||
|
||||
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
|
||||
// as quick swatches in the picker; the full picker below covers any custom color.
|
||||
const PRESETS: { label: string; value: string }[] = [
|
||||
{ label: 'Red', value: '#F1575F' },
|
||||
{ label: 'Orange', value: '#F5B225' },
|
||||
{ label: 'Green', value: '#2BB673' },
|
||||
{ label: 'Blue', value: '#4E74F8' },
|
||||
const PRESETS: { label: string; value: ThresholdColor }[] = [
|
||||
{ label: 'Red', value: ThresholdColor.RED },
|
||||
{ label: 'Orange', value: ThresholdColor.ORANGE },
|
||||
{ label: 'Green', value: ThresholdColor.GREEN },
|
||||
{ label: 'Blue', value: ThresholdColor.BLUE },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
@@ -22,7 +23,7 @@ import TableThresholdRow from './rows/TableThresholdRow';
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
// New thresholds default to red (the first palette preset); the user recolors per rule.
|
||||
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
|
||||
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
|
||||
|
||||
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
|
||||
const ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
|
||||
@@ -73,6 +73,25 @@ describe('usePanelEditorDraft', () => {
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
|
||||
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
|
||||
// against the persisted panel (`saved`) — so it starts dirty, not clean.
|
||||
const seed = panel('Memory', 'usage');
|
||||
const saved = panel('CPU', 'usage');
|
||||
|
||||
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean when the seed matches the saved baseline', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
// Exercises the REAL query builder provider (not mocks) so the dirty check is
|
||||
// verified against the builder's actual re-serialization — the "always dirty"
|
||||
// regression only reproduces with the real normalization in the loop.
|
||||
|
||||
const panelType = PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
function makeSavedQueries(): DashboardtypesQueryDTO[] {
|
||||
const base: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return toPerses(base, panelType);
|
||||
}
|
||||
|
||||
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
it('an untouched existing panel is NOT query-dirty on mount', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(saved),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
// The builder force-resets to the saved query asynchronously; once settled the
|
||||
// live query must serialize back to the saved queries → clean.
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
// And stays clean (no late re-stage flips it dirty).
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
|
||||
// An older saved query carries only a few fields; the builder re-emits many more
|
||||
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
|
||||
// this as always-dirty; the round-tripped baseline normalizes both sides.
|
||||
const minimalSaved: DashboardtypesQueryDTO[] = [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [
|
||||
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(minimalSaved),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: minimalSaved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
|
||||
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
|
||||
// carries the last-run (edited) query. The builder must hydrate from the URL —
|
||||
// not discard it — so the edit survives, and it must read dirty against saved.
|
||||
const saved = makeSavedQueries();
|
||||
const editedInUrl: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
id: 'edited-in-url',
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu-edited-in-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(editedInUrl)),
|
||||
);
|
||||
const setSpec = jest.fn();
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`/?${params.toString()}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
// The draft/preview open on the saved query…
|
||||
draft: makePanel(saved),
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// The URL edit is retained → dirty, and it's synced into the draft so the
|
||||
// preview follows (setSpec called with the edited query).
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
expect(setSpec).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
const editedBase: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu-edited',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const editedQueries = toPerses(editedBase, panelType);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
// The builder seeds from the draft (the handed-off edit)…
|
||||
draft: makePanel(editedQueries),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
// …but the baseline is the persisted panel.
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ describe('usePanelEditorQuerySync', () => {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
@@ -119,20 +120,22 @@ describe('usePanelEditorQuerySync', () => {
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
savedQueries: opts.savedQueries,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
|
||||
// no query, so an in-editor edit in the URL survives a refresh.
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,44 +345,127 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
describe('staged-query re-sync (browser back/forward)', () => {
|
||||
it('commits the staged query into the draft when it re-stages', () => {
|
||||
const state = builderState();
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Browser Back re-stages a different query via initQueryBuilderData; the
|
||||
// preview must follow it instead of keeping the last Run's result.
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
state.stagedQuery = {
|
||||
id: 'restaged',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when only the live query changes (no re-stage)', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Live edit: currentQuery changes, staged query + structure unchanged.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
|
||||
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
|
||||
// toPerses so the envelope comparison — not getIsQueryModified — decides.
|
||||
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const EDITED_ENVELOPES = [
|
||||
{ id: 'edited-envelopes' },
|
||||
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
|
||||
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
|
||||
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
|
||||
);
|
||||
});
|
||||
|
||||
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
it('is not query-dirty when the live query still serializes to the saved queries', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
queries: EDITED_ENVELOPES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
|
||||
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
|
||||
// The draft carries the View-mode edit (the builder seeds from it), but the
|
||||
// baseline is the persisted panel: a live query equal to the edited draft
|
||||
// still reads dirty against the saved queries.
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const draft = makeDraft(EDITED_ENVELOPES);
|
||||
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,12 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
|
||||
* panel or the drilldown modal, where the seed is the baseline.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
@@ -67,12 +73,15 @@ export interface UsePanelEditSessionReturn {
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
|
||||
panel,
|
||||
savedPanel,
|
||||
);
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
@@ -93,6 +102,7 @@ export function usePanelEditSession({
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
savedQueries: savedPanel?.spec.queries,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
|
||||
@@ -13,9 +13,14 @@ import type { PanelEditorDraftApi } from '../types';
|
||||
* preview renders it through the dashboard's renderer registry and the save hook
|
||||
* patches it without conversion. Everything the config pane edits flows through the
|
||||
* single `spec`/`setSpec` pair.
|
||||
*
|
||||
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
|
||||
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
|
||||
* Defaults to the seed when there's no separate saved baseline (a new panel).
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
savedPanel: DashboardtypesPanelDTO = initialPanel,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
@@ -35,9 +40,9 @@ export function usePanelEditorDraft(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, initialPanel],
|
||||
[draft, savedPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -27,6 +28,12 @@ interface UsePanelEditorQuerySyncArgs {
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/**
|
||||
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
|
||||
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
|
||||
* for a new panel, where the seed query is the baseline.
|
||||
*/
|
||||
savedQueries?: DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
@@ -53,43 +60,31 @@ export function usePanelEditorQuerySync({
|
||||
refetch,
|
||||
alwaysSerializeQuery = false,
|
||||
signal,
|
||||
savedQueries,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
const savedQueries = draft.spec.queries;
|
||||
const draftQueries = draft.spec.queries;
|
||||
|
||||
// A new panel has no saved query: seed from the kind's first supported signal
|
||||
// instead of letting `fromPerses` fall back to the metrics default (which List
|
||||
// doesn't support).
|
||||
// A new panel has no saved query: seed from the kind's first supported signal rather
|
||||
// than `fromPerses`'s metrics default (which List doesn't support).
|
||||
const seedQuery = useMemo(
|
||||
() =>
|
||||
savedQueries.length === 0 && signal
|
||||
draftQueries.length === 0 && signal
|
||||
? initialQueriesMap[signal]
|
||||
: fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType, signal],
|
||||
: fromPerses(draftQueries, panelType),
|
||||
[draftQueries, panelType, signal],
|
||||
);
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding a
|
||||
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
|
||||
// baseline is captured from the URL). After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
// No forceReset: seed the builder only when the URL carries no query, so an
|
||||
// in-editor edit in the URL survives a refresh / browser Back-Forward.
|
||||
useShareBuilderUrl({ defaultValue: seedQuery });
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query, which can carry stale state across a refresh and read a real
|
||||
// switch as "unchanged". Returns whether the draft changed.
|
||||
// Commit the live query into the draft (what the preview fetches).
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: savedQueries;
|
||||
: draftQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so a structural compare would falsely dirty the draft.
|
||||
@@ -100,7 +95,7 @@ export function usePanelEditorQuerySync({
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
@@ -110,7 +105,7 @@ export function usePanelEditorQuerySync({
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
// mount: the initial query is synced into the draft by the staged-query effect below.
|
||||
const dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
[currentQuery.builder],
|
||||
@@ -136,6 +131,15 @@ export function usePanelEditorQuerySync({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Follow the staged (executed) query into the draft on a URL re-stage (mount
|
||||
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
|
||||
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
|
||||
useEffect(() => {
|
||||
if (stagedQuery) {
|
||||
commitRef.current(stagedQuery);
|
||||
}
|
||||
}, [stagedQuery]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
@@ -144,20 +148,29 @@ export function usePanelEditorQuerySync({
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset) — comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
|
||||
// once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
|
||||
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
|
||||
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
|
||||
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
|
||||
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
|
||||
const baselineEnvelopes = useMemo(
|
||||
() =>
|
||||
toQueryEnvelopes(
|
||||
toPerses(
|
||||
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
|
||||
panelType,
|
||||
),
|
||||
),
|
||||
[savedQueries, seedQuery, panelType],
|
||||
);
|
||||
const isQueryDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
toQueryEnvelopes(toPerses(currentQuery, panelType)),
|
||||
baselineEnvelopes,
|
||||
),
|
||||
[currentQuery, panelType, baselineEnvelopes],
|
||||
);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
@@ -41,10 +42,22 @@ import styles from './PanelEditor.module.scss';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
// Portal them to the document body; the query-builder filters honor this via
|
||||
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
|
||||
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
|
||||
const getBodyPopupContainer = (): HTMLElement => document.body;
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
@@ -68,6 +81,7 @@ function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
@@ -91,6 +105,7 @@ function PanelEditorContainer({
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
@@ -288,22 +303,24 @@ function PanelEditorContainer({
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,22 @@ export interface ComparisonThresholdShape {
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
}
|
||||
|
||||
/** SigNoz threshold palette; single source of truth for the hex values. */
|
||||
export enum ThresholdColor {
|
||||
RED = '#F1575F',
|
||||
ORANGE = '#F5B225',
|
||||
GREEN = '#2BB673',
|
||||
BLUE = '#4E74F8',
|
||||
}
|
||||
|
||||
/** Palette ordered most-dangerous first (preset order + alert-severity ranking). */
|
||||
export const THRESHOLD_COLOR_DANGER_ORDER: ThresholdColor[] = [
|
||||
ThresholdColor.RED,
|
||||
ThresholdColor.ORANGE,
|
||||
ThresholdColor.GREEN,
|
||||
ThresholdColor.BLUE,
|
||||
];
|
||||
|
||||
/** Comparison operators a threshold can use, as evaluable symbols. */
|
||||
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ function ViewPanelModalContent({
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
|
||||
openPanelEditor(panelId, {
|
||||
handoffState: { editSpec: buildSaveSpec(draft.spec) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -105,7 +105,8 @@ export function useDrilldownDashboardVariables({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
dynamicAttribute: fieldName,
|
||||
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
|
||||
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
|
||||
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
@@ -5,11 +5,13 @@ import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { normalizeOperator } from 'container/CreateAlertV2/context/conditionNormalizers';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
Threshold,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
import type { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -21,14 +23,6 @@ export interface PanelAlertPrefill {
|
||||
threshold?: Threshold;
|
||||
}
|
||||
|
||||
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
|
||||
const THRESHOLD_COLOR_DANGER_ORDER = [
|
||||
'#f1575f',
|
||||
'#f5b225',
|
||||
'#2bb673',
|
||||
'#4e74f8',
|
||||
];
|
||||
|
||||
interface NormalizedPanelThreshold {
|
||||
color: string;
|
||||
value: number;
|
||||
@@ -93,8 +87,12 @@ function readPanelThresholds(
|
||||
}
|
||||
}
|
||||
|
||||
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
|
||||
function colorRank(color: string): number {
|
||||
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
|
||||
const target = color.toLowerCase();
|
||||
const index = THRESHOLD_COLOR_DANGER_ORDER.findIndex(
|
||||
(paletteColor) => paletteColor.toLowerCase() === target,
|
||||
);
|
||||
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
|
||||
}
|
||||
|
||||
@@ -106,22 +104,17 @@ function pickHighestDanger(
|
||||
)[0];
|
||||
}
|
||||
|
||||
// The alert UI has no inclusive operator; collapse "or equal" onto its strict variant.
|
||||
function panelOperatorToAlertOperator(
|
||||
operator: DashboardtypesComparisonOperatorDTO | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (operator) {
|
||||
case 'above':
|
||||
case 'above_or_equal':
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case 'below':
|
||||
return normalizeOperator('above');
|
||||
case 'below_or_equal':
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case 'equal':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case 'not_equal':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
return normalizeOperator('below');
|
||||
default:
|
||||
return undefined;
|
||||
return normalizeOperator(operator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
import { useShareVariablesOption } from './useShareVariablesOption';
|
||||
|
||||
import styles from './DashboardPageHeader.module.scss';
|
||||
|
||||
@@ -14,10 +15,16 @@ function DashboardPageHeader({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
const shareVariablesOption = useShareVariablesOption();
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
shareModalExtraOption={shareVariablesOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ShareURLExtraOption } from 'components/HeaderRightSection/ShareURLModal';
|
||||
|
||||
import type { SelectedVariableValue } from '../../VariablesBar/selectionTypes';
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from '../../VariablesBar/utils/variablesUrlState';
|
||||
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* The share-dialog "Include variables" option: serializes the current variable
|
||||
* selection into the `?variables=` param (ALL encoded as the sentinel) so a shared
|
||||
* link reproduces it for the recipient — who hydrates it into local storage on load,
|
||||
* after which the param is cleared (see useSeedVariableSelection). Returns undefined
|
||||
* when there is nothing selected to share.
|
||||
*/
|
||||
export function useShareVariablesOption(): ShareURLExtraOption | undefined {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const selections = useDashboardStore(selectVariableValues(dashboardId ?? ''));
|
||||
|
||||
return useMemo(() => {
|
||||
const names = Object.keys(selections);
|
||||
if (names.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const urlShape: Record<string, SelectedVariableValue> = {};
|
||||
names.forEach((name) => {
|
||||
const selection = selections[name];
|
||||
urlShape[name] = selection.allSelected ? ALL_SELECTED : selection.value;
|
||||
});
|
||||
const serialized = variablesUrlParser.serialize(urlShape);
|
||||
return {
|
||||
label: 'Include variables',
|
||||
apply: (params): void => {
|
||||
params.set('variables', serialized);
|
||||
},
|
||||
};
|
||||
}, [selections]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useCreatePanel } from '../useCreatePanel';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
describe('useCreatePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
|
||||
});
|
||||
|
||||
it('carries the relative time window onto the new-panel route', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('/dashboard/dash-1/panel/new');
|
||||
expect(url).toContain('panelKind=timeSeries');
|
||||
expect(url).toContain('layoutIndex=2');
|
||||
expect(url).toContain('relativeTime=6h');
|
||||
});
|
||||
|
||||
it('carries a custom absolute window and never a stray relativeTime', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('startTime=1000');
|
||||
expect(url).toContain('endTime=2000');
|
||||
expect(url).not.toContain('relativeTime');
|
||||
expect(url).not.toContain('&&');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useOpenPanelEditor } from '../useOpenPanelEditor';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
describe('useOpenPanelEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
|
||||
});
|
||||
|
||||
it('carries the relative time window into the editor route', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries a custom absolute window as a start/end ms pair', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('startTime=1000');
|
||||
expect(url).toContain('endTime=2000');
|
||||
// A custom range must not also carry relativeTime (it would win on the editor).
|
||||
expect(url).not.toContain('relativeTime');
|
||||
});
|
||||
|
||||
it('omits the query string for an uninitialized custom window', () => {
|
||||
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards handoff state as router location state', () => {
|
||||
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
const handoffState = { editSpec: { title: 'x' } } as never;
|
||||
result.current('panel-9', { handoffState });
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
|
||||
{ state: handoffState },
|
||||
);
|
||||
});
|
||||
|
||||
it('merges search with the time window (leading ? tolerated)', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('/dashboard/dash-1/panel/new?');
|
||||
expect(url).toContain('panelKind=timeSeries');
|
||||
expect(url).toContain('layoutIndex=2');
|
||||
expect(url).toContain('relativeTime=6h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
describe('useTimeSearchParams', () => {
|
||||
it('returns a relativeTime query string for a relative selection', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toBe('relativeTime=6h');
|
||||
});
|
||||
|
||||
it('returns an absolute ms pair for a custom selection', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toContain('startTime=1000');
|
||||
expect(result.current).toContain('endTime=2000');
|
||||
expect(result.current).not.toContain('relativeTime');
|
||||
});
|
||||
|
||||
it('returns an empty string for an uninitialized custom window', () => {
|
||||
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
|
||||
import type { PanelKind } from '../Panels/types/panelKind';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useOpenPanelEditor } from './useOpenPanelEditor';
|
||||
|
||||
interface UseCreatePanelResult {
|
||||
isPickerOpen: boolean;
|
||||
@@ -24,8 +21,7 @@ interface UseCreatePanelResult {
|
||||
* until save.
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
// Captured on open, consumed on select.
|
||||
@@ -43,15 +39,12 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
// Variable selection is read from the persisted store, not the URL.
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
openPanelEditor(NEW_PANEL_ID, {
|
||||
search: newPanelSearch(panelKind, target),
|
||||
});
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
[openPanelEditor, layoutIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,30 +5,39 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id. Variable selection is read from the
|
||||
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
|
||||
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
|
||||
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
|
||||
* panel.
|
||||
*/
|
||||
interface OpenPanelEditorOptions {
|
||||
handoffState?: PanelEditorHandoffState;
|
||||
/** Extra query merged into the editor URL (leading `?` optional). */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/** Opens the V2 panel editor, carrying the active time window in the URL. */
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
handoffState?: PanelEditorHandoffState,
|
||||
options?: OpenPanelEditorOptions,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
|
||||
(panelId: string, options?: OpenPanelEditorOptions): void => {
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
});
|
||||
const params = new URLSearchParams(options?.search);
|
||||
new URLSearchParams(timeSearch).forEach((value, key) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
const search = params.toString();
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
search ? `${path}?${search}` : path,
|
||||
options?.handoffState ? { state: options.handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId],
|
||||
[safeNavigate, dashboardId, timeSearch],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
|
||||
|
||||
/** Active time window as a query string (no leading `?`), or `''` when unset. */
|
||||
export function useTimeSearchParams(): string {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
return useMemo(
|
||||
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
|
||||
[selectedTime, minTime, maxTime],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../timeUrlParams';
|
||||
|
||||
describe('timeParamsFromGlobalTime', () => {
|
||||
it('emits relativeTime for a relative selection', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: '6h',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.get('relativeTime')).toBe('6h');
|
||||
// Mutually exclusive: no absolute pair alongside a relative range.
|
||||
expect(params.has('startTime')).toBe(false);
|
||||
expect(params.has('endTime')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
});
|
||||
|
||||
expect(params.get('startTime')).toBe('1000');
|
||||
expect(params.get('endTime')).toBe('2000');
|
||||
// A custom range must not carry a relativeTime that would win on the editor.
|
||||
expect(params.has('relativeTime')).toBe(false);
|
||||
});
|
||||
|
||||
it('carries a custom shorthand relative selection verbatim', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: '13m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.get('relativeTime')).toBe('13m');
|
||||
});
|
||||
|
||||
it('emits nothing for an uninitialized custom window', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: 'custom',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.toString()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
type GlobalTimeSelection = Pick<
|
||||
GlobalReducer,
|
||||
'selectedTime' | 'minTime' | 'maxTime'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Time-window URL params for the active selection. Derived from Redux (what the picker and
|
||||
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
|
||||
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
|
||||
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
|
||||
*/
|
||||
export function timeParamsFromGlobalTime({
|
||||
selectedTime,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: GlobalTimeSelection): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedTime === 'custom') {
|
||||
if (minTime > 0 && maxTime > 0) {
|
||||
params.set(
|
||||
QueryParams.startTime,
|
||||
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.endTime,
|
||||
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
params.set(QueryParams.relativeTime, selectedTime);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
@@ -38,6 +39,7 @@ function PanelEditorPage(): JSX.Element {
|
||||
}>();
|
||||
const { search, state } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
@@ -105,10 +107,11 @@ function PanelEditorPage(): JSX.Element {
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
|
||||
// variable selection from the persisted store, and time lives in Redux.
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [safeNavigate, dashboardId]);
|
||||
// Drop editor-only URL state (variables come from the persisted store), but carry
|
||||
// time so a custom range picked in the editor isn't reset to the dashboard default.
|
||||
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
|
||||
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
|
||||
}, [safeNavigate, dashboardId, timeSearch]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
@@ -137,6 +140,7 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId={dashboardId}
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
savedPanel={existingPanel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
isEditable={isEditable}
|
||||
|
||||
@@ -136,8 +136,8 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { ConfigProvider, SelectProps } from 'antd';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useContext } from 'react';
|
||||
|
||||
export const popupContainer: SelectProps['getPopupContainer'] = (
|
||||
trigger,
|
||||
): HTMLElement => trigger.parentNode;
|
||||
|
||||
/**
|
||||
* Popup container for query-builder Selects. Prefers a container supplied by an
|
||||
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
|
||||
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
|
||||
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
|
||||
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
|
||||
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
|
||||
*/
|
||||
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
|
||||
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
|
||||
return getPopupContainer ?? popupContainer;
|
||||
}
|
||||
|
||||
@@ -56,19 +56,21 @@ func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (
|
||||
}
|
||||
}
|
||||
|
||||
var metricName string
|
||||
// Without executing the series lookup, only an exact-name selector's
|
||||
// metric name is known.
|
||||
var metricNames []string
|
||||
for _, matcher := range query.Matchers {
|
||||
if matcher.Name == "__name__" {
|
||||
metricName = matcher.Value
|
||||
if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ {
|
||||
metricNames = []string{matcher.Value}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the executing path's queries, but only record them.
|
||||
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
|
||||
sub, err := seriesLookupQuery(query, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
|
||||
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
|
||||
c.recorder.record(samplesQuery, samplesArgs)
|
||||
|
||||
return storage.EmptySeriesSet(), nil
|
||||
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
@@ -56,19 +56,13 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
|
||||
}
|
||||
}
|
||||
|
||||
var metricName string
|
||||
for _, matcher := range query.Matchers {
|
||||
if matcher.Name == "__name__" {
|
||||
metricName = matcher.Value
|
||||
}
|
||||
}
|
||||
|
||||
clickhouseQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, false)
|
||||
lookup, err := seriesLookupQuery(query, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
fingerprints, err := client.getFingerprintsFromClickhouseQuery(ctx, clickhouseQuery, args)
|
||||
fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,13 +70,14 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
|
||||
return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil
|
||||
}
|
||||
|
||||
clickhouseSubQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, true)
|
||||
sub, err := seriesLookupQuery(query, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
|
||||
|
||||
res := new(prompb.QueryResult)
|
||||
timeseries, err := client.querySamples(ctx, int64(query.StartTimestampMs), int64(query.EndTimestampMs), fingerprints, metricName, clickhouseSubQuery, args)
|
||||
timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -126,86 +121,115 @@ func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sort
|
||||
return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil
|
||||
}
|
||||
|
||||
func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Query, metricName string, subQuery bool) (string, []any, error) {
|
||||
var clickHouseQuery string
|
||||
var conditions []string
|
||||
var argCount = 0
|
||||
var selectString = "fingerprint, any(labels)"
|
||||
// anchorRegex makes a pattern fully anchored, the way Prometheus compiles
|
||||
// matcher regexes; ClickHouse's match() would otherwise substring-match.
|
||||
func anchorRegex(pattern string) string {
|
||||
return "^(?:" + pattern + ")$"
|
||||
}
|
||||
|
||||
// seriesLookupQuery builds the time-series lookup. It returns a builder so
|
||||
// the samples query can embed it as a subquery with the args merged in
|
||||
// render order by the builder instead of hand-numbered placeholders.
|
||||
func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if subQuery {
|
||||
argCount = 1
|
||||
selectString = "fingerprint"
|
||||
sb.Select("fingerprint")
|
||||
} else {
|
||||
sb.Select("fingerprint", "any(labels)")
|
||||
}
|
||||
|
||||
start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs)
|
||||
sb.From(databaseName + "." + tableName)
|
||||
|
||||
var args []any
|
||||
conditions = append(conditions, fmt.Sprintf("metric_name = $%d", argCount+1))
|
||||
conditions = append(conditions, "temporality IN ['Cumulative', 'Unspecified']")
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
// Inclusive upper bound: registration rows are hour-floored by the
|
||||
// exporter, so a series first registered in the hour starting exactly at
|
||||
// `end` would otherwise be invisible while its samples (<= end) are in
|
||||
// range.
|
||||
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
|
||||
sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
|
||||
|
||||
args = append(args, metricName)
|
||||
for _, m := range query.Matchers {
|
||||
if m.Name == "__name__" {
|
||||
// __name__ maps onto the metric_name column per matcher type;
|
||||
// reducing regex/negated/absent name matchers to one equality
|
||||
// made such selectors silently return empty.
|
||||
switch m.Type {
|
||||
case prompb.LabelMatcher_EQ:
|
||||
sb.Where(sb.E("metric_name", m.Value))
|
||||
case prompb.LabelMatcher_NEQ:
|
||||
sb.Where(sb.NE("metric_name", m.Value))
|
||||
case prompb.LabelMatcher_RE:
|
||||
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
case prompb.LabelMatcher_NRE:
|
||||
sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case prompb.LabelMatcher_EQ:
|
||||
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) = $%d", argCount+2, argCount+3))
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case prompb.LabelMatcher_NEQ:
|
||||
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) != $%d", argCount+2, argCount+3))
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case prompb.LabelMatcher_RE:
|
||||
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
|
||||
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
case prompb.LabelMatcher_NRE:
|
||||
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
|
||||
sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
|
||||
}
|
||||
args = append(args, m.Name, m.Value)
|
||||
argCount += 2
|
||||
}
|
||||
|
||||
whereClause := strings.Join(conditions, " AND ")
|
||||
|
||||
clickHouseQuery = fmt.Sprintf(`SELECT %s FROM %s.%s WHERE %s GROUP BY fingerprint`, selectString, databaseName, tableName, whereClause)
|
||||
|
||||
return clickHouseQuery, args, nil
|
||||
sb.GroupBy("fingerprint")
|
||||
return sb, nil
|
||||
}
|
||||
|
||||
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) {
|
||||
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fingerprints := make(map[uint64][]prompb.Label)
|
||||
nameSet := make(map[string]struct{})
|
||||
|
||||
var fingerprint uint64
|
||||
var labelString string
|
||||
for rows.Next() {
|
||||
if err = rows.Scan(&fingerprint, &labelString); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
labels, _, err := unmarshalLabels(labelString)
|
||||
labels, metricName, err := unmarshalLabels(labelString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fingerprints[fingerprint] = labels
|
||||
if metricName != "" {
|
||||
nameSet[metricName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return fingerprints, nil
|
||||
metricNames := make([]string, 0, len(nameSet))
|
||||
for name := range nameSet {
|
||||
metricNames = append(metricNames, name)
|
||||
}
|
||||
sort.Strings(metricNames)
|
||||
|
||||
return fingerprints, metricNames, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples SQL (and args) that fetches data
|
||||
// points for the series selected by subQuery.
|
||||
// buildSamplesQuery renders the samples SQL for the series selected by
|
||||
// subQuery. The metric_name condition exists only for primary-key pruning;
|
||||
// the fingerprint filter already selects the right rows.
|
||||
//
|
||||
// Time bounds are inclusive on both ends because that is Prometheus's
|
||||
// storage contract: Select(mint, maxt) returns [start, end] and the engine
|
||||
@@ -215,27 +239,29 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
|
||||
// its own model — toStartOfInterval buckets covering [t, t+step), where a
|
||||
// sample at `end` falls in an unrendered bucket and end-exclusive ranges
|
||||
// tile exactly across cached time slices.
|
||||
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
|
||||
argCount := len(args)
|
||||
func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags")
|
||||
sb.From(databaseName + "." + distributedSamplesV4)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT metric_name, fingerprint, unix_milli, value, flags
|
||||
FROM %s.%s
|
||||
WHERE metric_name = $1 AND fingerprint GLOBAL IN (%s) AND unix_milli >= $%s AND unix_milli <= $%s ORDER BY fingerprint, unix_milli;`,
|
||||
databaseName, distributedSamplesV4, subQuery, strconv.Itoa(argCount+2), strconv.Itoa(argCount+3))
|
||||
query = strings.TrimSpace(query)
|
||||
if len(metricNames) > 0 {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
sb.Where(sb.In("metric_name", names...))
|
||||
}
|
||||
sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub)))
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
sb.OrderBy("fingerprint", "unix_milli")
|
||||
|
||||
allArgs := append([]any{metricName}, args...)
|
||||
allArgs = append(allArgs, start, end)
|
||||
return query, allArgs
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
|
||||
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
|
||||
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,6 +269,7 @@ func (client *client) querySamples(ctx context.Context, start int64, end int64,
|
||||
|
||||
var res []*prompb.TimeSeries
|
||||
var ts *prompb.TimeSeries
|
||||
var metricName string
|
||||
var fingerprint, prevFingerprint uint64
|
||||
var timestampMs, prevTimestamp int64
|
||||
var value float64
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -29,7 +30,7 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
start int64
|
||||
end int64
|
||||
fingerprints map[uint64][]prompb.Label
|
||||
metricName string
|
||||
metricNames []string
|
||||
subQuery string
|
||||
args []any
|
||||
setupMock func(mock cmock.ClickConnMockCommon, args ...any)
|
||||
@@ -52,7 +53,7 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
{Name: "instance", Value: "localhost:9091"},
|
||||
},
|
||||
},
|
||||
metricName: "cpu_usage",
|
||||
metricNames: []string{"cpu_usage"},
|
||||
subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags",
|
||||
expectedTimeSeries: 2,
|
||||
expectError: false,
|
||||
@@ -97,10 +98,10 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
|
||||
readClient := client{telemetryStore: telemetryStore}
|
||||
if tt.setupMock != nil {
|
||||
tt.setupMock(telemetryStore.Mock(), tt.metricName, tt.start, tt.end)
|
||||
tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end)
|
||||
|
||||
}
|
||||
result, err := readClient.querySamples(ctx, tt.start, tt.end, tt.fingerprints, tt.metricName, tt.subQuery, tt.args)
|
||||
result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
@@ -115,101 +116,6 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
|
||||
cols := []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
|
||||
sortLabels := func(ls []prompb.Label) {
|
||||
sort.Slice(ls, func(i, j int) bool {
|
||||
if ls[i].Name == ls[j].Name {
|
||||
return ls[i].Value < ls[j].Value
|
||||
}
|
||||
return ls[i].Name < ls[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start, end int64
|
||||
metricName string
|
||||
subQuery string
|
||||
args []any
|
||||
setupMock func(m cmock.ClickConnMockCommon, args ...any)
|
||||
want map[uint64][]prompb.Label
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "happy-path - two fingerprints",
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
metricName: "cpu_usage",
|
||||
subQuery: `SELECT fingerprint,labels`,
|
||||
// args slice is empty here, but test‑case still owns it
|
||||
args: []any{},
|
||||
|
||||
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
|
||||
rows := [][]any{
|
||||
{uint64(123), `{"t1":"s1","t2":"s2"}`},
|
||||
{uint64(234), `{"t1":"s1","t2":"s2","empty":""}`},
|
||||
}
|
||||
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
|
||||
cmock.NewRows(cols, rows),
|
||||
)
|
||||
},
|
||||
|
||||
// No synthetic fingerprint label (#8563), empty-valued labels
|
||||
// dropped: both fingerprints present one labelset for
|
||||
// querySamples to merge.
|
||||
want: map[uint64][]prompb.Label{
|
||||
123: {
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
234: {
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := telemetrystoretest.New(
|
||||
telemetrystore.Config{Provider: "clickhouse"},
|
||||
sqlmock.QueryMatcherRegexp,
|
||||
)
|
||||
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(store.Mock(), tc.args...)
|
||||
}
|
||||
|
||||
c := client{telemetryStore: store}
|
||||
|
||||
got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
|
||||
for fp, expLabels := range tc.want {
|
||||
gotLabels, ok := got[fp]
|
||||
require.Truef(t, ok, "missing fingerprint %d", fp)
|
||||
|
||||
sortLabels(expLabels)
|
||||
sortLabels(gotLabels)
|
||||
|
||||
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the duplicate-series class behind #8563: fingerprints
|
||||
// sharing one labelset must come back as one merged series, the higher
|
||||
// fingerprint winning equal timestamps.
|
||||
@@ -251,7 +157,7 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
|
||||
WillReturnRows(cmock.NewRows(cols, values))
|
||||
|
||||
readClient := client{telemetryStore: telemetryStore}
|
||||
result, err := readClient.querySamples(ctx, 1000, 3000, fingerprints, "requests", "SELECT metric_name, fingerprint, unix_milli, value, flags", nil)
|
||||
result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []*prompb.TimeSeries{
|
||||
@@ -272,6 +178,188 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
|
||||
cols := []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
|
||||
sortLabels := func(ls []prompb.Label) {
|
||||
sort.Slice(ls, func(i, j int) bool {
|
||||
if ls[i].Name == ls[j].Name {
|
||||
return ls[i].Value < ls[j].Value
|
||||
}
|
||||
return ls[i].Name < ls[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start, end int64
|
||||
metricName string
|
||||
subQuery string
|
||||
args []any
|
||||
setupMock func(m cmock.ClickConnMockCommon, args ...any)
|
||||
want map[uint64][]prompb.Label
|
||||
wantNames []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "happy-path - two fingerprints",
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
metricName: "cpu_usage",
|
||||
subQuery: `SELECT fingerprint,labels`,
|
||||
// args slice is empty here, but test‑case still owns it
|
||||
args: []any{},
|
||||
|
||||
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
|
||||
rows := [][]any{
|
||||
{uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`},
|
||||
{uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`},
|
||||
}
|
||||
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
|
||||
cmock.NewRows(cols, rows),
|
||||
)
|
||||
},
|
||||
|
||||
// No synthetic fingerprint label (#8563), empty-valued labels
|
||||
// dropped: both fingerprints present one labelset for
|
||||
// querySamples to merge.
|
||||
want: map[uint64][]prompb.Label{
|
||||
123: {
|
||||
{Name: "__name__", Value: "cpu_usage"},
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
234: {
|
||||
{Name: "__name__", Value: "cpu_usage"},
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
},
|
||||
wantNames: []string{"cpu_usage"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := telemetrystoretest.New(
|
||||
telemetrystore.Config{Provider: "clickhouse"},
|
||||
sqlmock.QueryMatcherRegexp,
|
||||
)
|
||||
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(store.Mock(), tc.args...)
|
||||
}
|
||||
|
||||
c := client{telemetryStore: store}
|
||||
|
||||
got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch")
|
||||
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
|
||||
for fp, expLabels := range tc.want {
|
||||
gotLabels, ok := got[fp]
|
||||
require.Truef(t, ok, "missing fingerprint %d", fp)
|
||||
|
||||
sortLabels(expLabels)
|
||||
sortLabels(gotLabels)
|
||||
|
||||
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for nameless/regex-name selectors silently returning empty:
|
||||
// the old code reduced every __name__ matcher to `metric_name = <value>`
|
||||
// (empty string when absent). Regexes must come out anchored — Prometheus
|
||||
// matcher semantics, while ClickHouse match() substring-matches.
|
||||
func TestQueryToClickhouseQueryNameMatchers(t *testing.T) {
|
||||
query := func(matchers ...*prompb.LabelMatcher) *prompb.Query {
|
||||
return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query *prompb.Query
|
||||
contains []string
|
||||
absent []string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
name: "exact name",
|
||||
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}),
|
||||
contains: []string{"metric_name = ?"},
|
||||
args: []any{"cpu_usage"},
|
||||
},
|
||||
{
|
||||
name: "regex name is anchored",
|
||||
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}),
|
||||
contains: []string{"match(metric_name, ?)"},
|
||||
args: []any{"^(?:.+)$"},
|
||||
},
|
||||
{
|
||||
name: "nameless selector has no metric_name condition",
|
||||
query: query(
|
||||
&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"},
|
||||
&prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"},
|
||||
),
|
||||
contains: []string{
|
||||
"JSONExtractString(labels, ?) = ?",
|
||||
"not match(JSONExtractString(labels, ?), ?)",
|
||||
},
|
||||
absent: []string{"metric_name"},
|
||||
args: []any{"job", "api", "group", "^(?:can.*)$"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lookup, err := seriesLookupQuery(tt.query, false)
|
||||
require.NoError(t, err)
|
||||
sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
for _, want := range tt.contains {
|
||||
assert.Contains(t, sql, want)
|
||||
}
|
||||
for _, notWant := range tt.absent {
|
||||
assert.NotContains(t, sql, notWant)
|
||||
}
|
||||
assert.Equal(t, tt.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The samples query narrows by the metric names the lookup discovered and
|
||||
// embeds the series lookup as a subquery, the builder merging its args in
|
||||
// render order.
|
||||
func TestBuildSamplesQueryMetricNames(t *testing.T) {
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint")
|
||||
sub.From("t")
|
||||
sub.Where(sub.E("k", "v"))
|
||||
|
||||
sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub)
|
||||
assert.Contains(t, sql, "metric_name IN (?, ?)")
|
||||
assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)")
|
||||
assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?")
|
||||
assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args)
|
||||
|
||||
sub2 := sqlbuilder.NewSelectBuilder()
|
||||
sub2.Select("fingerprint")
|
||||
sub2.From("t")
|
||||
sql, args = buildSamplesQuery(5, 9, nil, sub2)
|
||||
assert.NotContains(t, sql, "metric_name IN")
|
||||
assert.Equal(t, []any{int64(5), int64(9)}, args)
|
||||
}
|
||||
|
||||
// Hash grouping must stay order-insensitive (stored JSON key order is not
|
||||
// canonical across fingerprints), and a 64-bit hash collision between
|
||||
// distinct labelsets must not merge them — splitByLabelSet is that guard.
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
|
||||
// Mock the fingerprint query (for Prometheus label matching)
|
||||
mock.ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// Mock the samples query (for Prometheus metric data)
|
||||
@@ -245,8 +245,6 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
|
||||
@@ -925,20 +925,18 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
}
|
||||
samplesRows := cmock.NewRows(samplesCols, samplesData)
|
||||
|
||||
// args: $1=metric_name, $2=label_name, $3=label_value
|
||||
// args: $1=metric_name (the __name__ matcher maps onto the column)
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end
|
||||
// args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
@@ -1063,7 +1061,7 @@ func TestPromRuleNoData(t *testing.T) {
|
||||
// no rows == no data
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
|
||||
@@ -1273,7 +1271,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
telemetryStore.Mock().
|
||||
@@ -1281,8 +1279,6 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
@@ -1439,12 +1435,12 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
labelsJSON := `{"__name__":"test_metric"}`
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
|
||||
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
|
||||
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
|
||||
|
||||
promProvider := prometheustest.New(
|
||||
@@ -1575,11 +1571,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
queryStart1, queryEnd1 := calcQueryRange(t1)
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
|
||||
WithArgs("test_metric", "test_metric", queryStart1, queryEnd1).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
|
||||
// Data points in the past relative to t1
|
||||
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
|
||||
@@ -1591,11 +1587,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
queryStart2, queryEnd2 := calcQueryRange(t2)
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
|
||||
WithArgs("test_metric", "test_metric", queryStart2, queryEnd2).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
|
||||
|
||||
promProvider := prometheustest.New(
|
||||
@@ -1752,11 +1748,11 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
|
||||
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, samplesData))
|
||||
promProvider := prometheustest.New(
|
||||
context.Background(),
|
||||
|
||||
@@ -181,6 +181,15 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
// dynamic array elements will be default casted to string
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
case telemetrytypes.FieldDataTypeUnspecified:
|
||||
if operator == qbtypes.FilterOperatorUnknown {
|
||||
switch value.(type) {
|
||||
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
|
||||
tblFieldName = accurateCastFloat(tblFieldName)
|
||||
case string:
|
||||
tblFieldName = castString(tblFieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tblFieldName, value
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package querybuilder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,38 +13,8 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"service.name": {},
|
||||
}
|
||||
|
||||
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
|
||||
|
||||
func EscapeTelemetryValue(value string) string {
|
||||
var escaped strings.Builder
|
||||
for _, character := range []byte(value) {
|
||||
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
|
||||
escaped.WriteByte(character)
|
||||
continue
|
||||
}
|
||||
escaped.WriteString(fmt.Sprintf("%%%02X", character))
|
||||
}
|
||||
|
||||
return escaped.String()
|
||||
}
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := []string{id}
|
||||
segments := strings.Split(id, "/")
|
||||
for level := len(segments) - 1; level >= 1; level-- {
|
||||
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
|
||||
if value == id {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if id != coretypes.WildCardSelectorString {
|
||||
values = append(values, coretypes.WildCardSelectorString)
|
||||
}
|
||||
values := telemetrytypes.NewTelemetryGrantSelectors(id)
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -187,14 +156,14 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := canonicalTelemetryGrantKey(condition.Key)
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if condition.Operator == "=" || condition.Operator == "IN" {
|
||||
for _, value := range condition.Values {
|
||||
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
|
||||
ids = append(ids, queryType+"/"+key+"/"+value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,16 +174,3 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -22,33 +21,33 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
expected []coretypes.ResourceWithID
|
||||
}{
|
||||
{
|
||||
name: "top level service equality",
|
||||
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
|
||||
name: "top level key equality",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed service key",
|
||||
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
|
||||
name: "resource prefixed key",
|
||||
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms each require a grant",
|
||||
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -59,38 +58,38 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
|
||||
name: "key atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated service atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT service.name = 'a'"),
|
||||
name: "negated key atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service inequality does not qualify",
|
||||
body: builderQueryBody("logs", "service.name != 'a'"),
|
||||
name: "key inequality does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsafe value bytes are escaped",
|
||||
body: builderQueryBody("logs", "service.name = 'check out/2'"),
|
||||
name: "value with spaces and slashes stays plaintext in the id",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit source maps to audit logs resource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,23 +116,23 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "trace operator rides on its referenced queries",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable substitution qualifies",
|
||||
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
|
||||
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -151,7 +150,7 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"compositeQuery":{"queries":[]}}`,
|
||||
`{}`,
|
||||
builderQueryBody("logs", "service.name = "),
|
||||
builderQueryBody("logs", "signoz.workspace.key.id = "),
|
||||
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
|
||||
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
|
||||
}
|
||||
@@ -175,10 +174,10 @@ func TestTelemetrySelector(t *testing.T) {
|
||||
return values
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
|
||||
|
||||
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
|
||||
assert.Equal(t, []string{"*"}, selectorValues("*"))
|
||||
// a value containing "/" stays one logical segment (SplitN 3).
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
|
||||
}
|
||||
|
||||
@@ -162,18 +162,28 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
|
||||
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
|
||||
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
} else {
|
||||
if len(fieldKeys[key.Name]) == 0 {
|
||||
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
|
||||
}
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{
|
||||
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
|
||||
}
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
|
||||
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -181,21 +191,3 @@ func (c *conditionBuilder) ConditionFor(
|
||||
}
|
||||
return conds, warnings, nil
|
||||
}
|
||||
|
||||
func (c *conditionBuilder) conditionForKey(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) (string, error) {
|
||||
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
@@ -307,3 +307,86 @@ func TestConditionForMultipleKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionForKeyNotInMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
expectedSQL []string
|
||||
expectWarn bool
|
||||
}{
|
||||
{
|
||||
name: "intrinsic metric_name full-text resolves without warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorRegexp,
|
||||
value: "k8s",
|
||||
expectedSQL: []string{"match(metric_name, ?)"},
|
||||
expectWarn: false,
|
||||
},
|
||||
{
|
||||
name: "unknown label resolves to labels extract with a typo warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextUnspecified},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "bar",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "context prefix that may be part of the name tries both readings",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "a.b.c", FieldContext: telemetrytypes.FieldContextScope},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "x",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'a.b.c') = ?", "JSONExtractString(labels, 'scope.a.b.c') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "unresolved metric-context name is treated as a label prefix",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "bar",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?", "JSONExtractString(labels, 'metric.foo') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "known label under a mismatched context collapses without warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "region", FieldContext: telemetrytypes.FieldContextResource},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"region": {{Name: "region", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "us",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'region') = ?"},
|
||||
expectWarn: false,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
conditionBuilder := NewConditionBuilder(fm)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
cond, warnings, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, tc.fieldKeys, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(cond...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
for _, want := range tc.expectedSQL {
|
||||
assert.Contains(t, sql, want)
|
||||
}
|
||||
if tc.expectWarn {
|
||||
assert.NotEmpty(t, warnings)
|
||||
} else {
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,23 +49,24 @@ func (c *conditionBuilder) conditionFor(
|
||||
|
||||
// TODO(srikanthccv): maybe extend this to every possible attribute
|
||||
if key.Name == "duration_nano" || key.Name == "durationNano" { // QoL improvement
|
||||
if strDuration, ok := value.(string); ok {
|
||||
duration, err := time.ParseDuration(strDuration)
|
||||
if err == nil {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if duration, err := time.ParseDuration(v); err == nil {
|
||||
value = duration.Nanoseconds()
|
||||
} else if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
value = int64(f)
|
||||
} else {
|
||||
duration, err := strconv.ParseFloat(strDuration, 64)
|
||||
if err == nil {
|
||||
value = duration
|
||||
} else {
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", strDuration)
|
||||
}
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", v)
|
||||
}
|
||||
case float64:
|
||||
value = int64(v)
|
||||
case float32:
|
||||
value = int64(v)
|
||||
}
|
||||
} else {
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
}
|
||||
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
|
||||
// regular operators
|
||||
switch operator {
|
||||
// regular operators
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -343,7 +343,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, duration_nano, mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -106,10 +107,6 @@ func NewGettableTransaction(results []*TransactionWithAuthorization) []*Gettable
|
||||
return gettableTransactions
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, deletions TransactionGroups) {
|
||||
return desired.subtract(groups), groups.subtract(desired)
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
@@ -168,51 +165,6 @@ func (transaction *Transaction) TransactionKey() string {
|
||||
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) subtract(other TransactionGroups) TransactionGroups {
|
||||
otherSelectors := other.selectorSet()
|
||||
|
||||
order := make([]string, 0)
|
||||
grouped := make(map[string]*TransactionGroup)
|
||||
for _, group := range groups {
|
||||
for _, selector := range group.ObjectGroup.Selectors {
|
||||
if _, ok := otherSelectors[group.selectorKey(selector)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
groupKey := group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String()
|
||||
out, ok := grouped[groupKey]
|
||||
if !ok {
|
||||
out = &TransactionGroup{Relation: group.Relation, ObjectGroup: coretypes.ObjectGroup{Resource: group.ObjectGroup.Resource, Selectors: make([]coretypes.Selector, 0)}}
|
||||
grouped[groupKey] = out
|
||||
order = append(order, groupKey)
|
||||
}
|
||||
out.ObjectGroup.Selectors = append(out.ObjectGroup.Selectors, selector)
|
||||
}
|
||||
}
|
||||
|
||||
result := make(TransactionGroups, 0, len(order))
|
||||
for _, key := range order {
|
||||
result = append(result, grouped[key])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) selectorSet() map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, group := range groups {
|
||||
for _, selector := range group.ObjectGroup.Selectors {
|
||||
set[group.selectorKey(selector)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
|
||||
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
|
||||
}
|
||||
|
||||
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
|
||||
verb, err := coretypes.NewVerb(raw.Relation)
|
||||
if err != nil {
|
||||
@@ -236,6 +188,13 @@ func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup,
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
|
||||
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
|
||||
if resourceType.Equals(coretypes.TypeTelemetryResource) {
|
||||
rawSelector, err = telemetrytypes.NewTelemetryGrantSelector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
selector, err := resourceType.Selector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
|
||||
@@ -3,6 +3,7 @@ package authtypes
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
@@ -68,6 +69,38 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func DiffTuples(existing, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
|
||||
key := func(tuple *openfgav1.TupleKey) string {
|
||||
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
|
||||
}
|
||||
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, tuple := range existing {
|
||||
existingSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
desiredSet := make(map[string]struct{}, len(desired))
|
||||
for _, tuple := range desired {
|
||||
desiredSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
additions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range desired {
|
||||
if _, ok := existingSet[key(tuple)]; !ok {
|
||||
additions = append(additions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
deletions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range existing {
|
||||
if _, ok := desiredSet[key(tuple)]; !ok {
|
||||
deletions = append(deletions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
return additions, deletions
|
||||
}
|
||||
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
|
||||
objectsByRelation := make(map[string][]*coretypes.Object)
|
||||
|
||||
@@ -109,17 +142,29 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectorStrings, err := newCheckSelectors(txn.Object.Resource.Type, txn.Object.Selector)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(selectorStrings))
|
||||
for _, selectorString := range selectorStrings {
|
||||
selector, err := txn.Object.Resource.Type.Selector(selectorString)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
selectors = append(selectors, selector)
|
||||
}
|
||||
|
||||
txnID := txn.ID.StringValue()
|
||||
|
||||
txnTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{txn.Object.Selector}, orgID)
|
||||
tuples[txnID] = txnTuples[0]
|
||||
|
||||
if txn.Object.Selector.String() != coretypes.WildCardSelectorString {
|
||||
wildcardSelector := txn.Object.Resource.Type.MustSelector(coretypes.WildCardSelectorString)
|
||||
wildcardTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{wildcardSelector}, orgID)
|
||||
for index, tuple := range NewTuples(resource, subject, txn.Relation, selectors, orgID) {
|
||||
if index == 0 {
|
||||
tuples[txnID] = tuple
|
||||
continue
|
||||
}
|
||||
|
||||
correlationID := valuer.GenerateUUID().StringValue()
|
||||
tuples[correlationID] = wildcardTuples[0]
|
||||
tuples[correlationID] = tuple
|
||||
correlations[txnID] = append(correlations[txnID], correlationID)
|
||||
}
|
||||
}
|
||||
@@ -214,3 +259,21 @@ func NewTransactionWithAuthorizationFromBatchResults(
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func newCheckSelectors(resourceType coretypes.Type, selector coretypes.Selector) ([]string, error) {
|
||||
if resourceType.Equals(coretypes.TypeTelemetryResource) {
|
||||
canonical, err := telemetrytypes.NewTelemetryGrantSelector(selector.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return telemetrytypes.NewTelemetryGrantSelectors(canonical), nil
|
||||
}
|
||||
|
||||
selectorStrings := []string{selector.String()}
|
||||
if selector.String() != coretypes.WildCardSelectorString {
|
||||
selectorStrings = append(selectorStrings, coretypes.WildCardSelectorString)
|
||||
}
|
||||
|
||||
return selectorStrings, nil
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@ var (
|
||||
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
|
||||
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []Verb{VerbRead}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^.{1,512}$`), []Verb{VerbRead}}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -26,7 +29,18 @@ func (resourceTelemetryResource *resourceTelemetryResource) Prefix(orgID valuer.
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Object(orgID valuer.UUID, selector string) string {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
if selector == WildCardSelectorString {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
}
|
||||
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + telemetrySelectorHash(selector)
|
||||
}
|
||||
|
||||
// Must stay stable: grant-time and check-time tuple objects both hash the selector
|
||||
// here, so changing this invalidates every stored telemetry grant tuple.
|
||||
func telemetrySelectorHash(selector string) string {
|
||||
sum := sha256.Sum256([]byte(selector))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Scope(verb Verb) string {
|
||||
|
||||
@@ -613,7 +613,7 @@ func (o OrderBy) Copy() OrderBy {
|
||||
type SecondaryAggregation struct {
|
||||
// stepInterval of the query
|
||||
// if not set, it will use the step interval of the primary aggregation
|
||||
StepInterval Step `json:"stepInterval,omitempty"`
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
// expression to aggregate. example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
|
||||
@@ -16,7 +16,7 @@ type QueryBuilderQuery[T any] struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// stepInterval of the query
|
||||
StepInterval Step `json:"stepInterval,omitempty"`
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
|
||||
// signal to query
|
||||
Signal telemetrytypes.Signal `json:"signal,omitempty"`
|
||||
|
||||
@@ -709,3 +709,40 @@ func TestQueryBuilderQuery_MetricAggregation_MarshalJSONEnumRoundTrip(t *testing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stepInterval is a struct-backed Step, so omitempty had no effect and an unset
|
||||
// value serialized as 0; ,omitzero omits it when unset while still echoing a set
|
||||
// value (as seconds), keeping the create -> GET round-trip stable.
|
||||
func TestQueryBuilderQuery_StepInterval_MarshalRoundTrip(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
query QueryBuilderQuery[LogAggregation]
|
||||
present []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "UnsetStepIntervalIsOmitted",
|
||||
query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs},
|
||||
absent: []string{`"stepInterval"`},
|
||||
},
|
||||
{
|
||||
name: "SetStepIntervalIsSerializedAsSeconds",
|
||||
query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs, StepInterval: Step{60 * time.Second}},
|
||||
present: []string{`"stepInterval":60`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
expected, err := json.Marshal(testCase.query)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, fragment := range testCase.present {
|
||||
assert.Contains(t, string(expected), fragment)
|
||||
}
|
||||
for _, fragment := range testCase.absent {
|
||||
assert.NotContains(t, string(expected), fragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
{4.0, 5.0, 6.0},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`,
|
||||
},
|
||||
{
|
||||
name: "scalar data with NaN",
|
||||
@@ -149,7 +149,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
{math.Inf(1), 5.0, math.Inf(-1)},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`,
|
||||
},
|
||||
{
|
||||
name: "scalar data with mixed types",
|
||||
@@ -168,7 +168,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
{nil, math.Inf(1), 3.14, false},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"mixed","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"mixed","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`,
|
||||
},
|
||||
{
|
||||
name: "scalar data with nested structures",
|
||||
@@ -189,7 +189,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"nested","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"nested","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`,
|
||||
},
|
||||
{
|
||||
name: "empty scalar data",
|
||||
|
||||
@@ -51,7 +51,7 @@ type QueryBuilderTraceOperator struct {
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
|
||||
Aggregations []TraceAggregation `json:"aggregations,omitzero"`
|
||||
StepInterval Step `json:"stepInterval,omitempty"`
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero"`
|
||||
|
||||
// having clause to apply to the aggregated query results
|
||||
|
||||
@@ -30,12 +30,16 @@ const (
|
||||
)
|
||||
|
||||
type TelemetryFieldKey struct {
|
||||
Name string `json:"name" validate:"required" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Signal Signal `json:"signal,omitzero"`
|
||||
FieldContext FieldContext `json:"fieldContext,omitzero"`
|
||||
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
|
||||
Name string `json:"name" validate:"required" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// signal/fieldContext/fieldDataType always serialize (empty included): the empty
|
||||
// value is a first-class "unspecified / any" selection a client can set, so it
|
||||
// must round-trip verbatim rather than be dropped. Their Enum()s include the
|
||||
// empty member so the "" is a valid schema value.
|
||||
Signal Signal `json:"signal"`
|
||||
FieldContext FieldContext `json:"fieldContext"`
|
||||
FieldDataType FieldDataType `json:"fieldDataType"`
|
||||
|
||||
JSONPlan JSONAccessPlan `json:"-"`
|
||||
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
|
||||
|
||||
@@ -185,5 +185,6 @@ func (FieldContext) Enum() []any {
|
||||
FieldContextAttribute,
|
||||
// FieldContextEvent,
|
||||
FieldContextBody,
|
||||
FieldContextUnspecified,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ func (FieldDataType) Enum() []any {
|
||||
FieldDataTypeFloat64,
|
||||
FieldDataTypeInt64,
|
||||
FieldDataTypeNumber,
|
||||
FieldDataTypeUnspecified,
|
||||
// FieldDataTypeArrayString,
|
||||
// FieldDataTypeArrayFloat64,
|
||||
// FieldDataTypeArrayBool,
|
||||
|
||||
110
pkg/types/telemetrytypes/selector.go
Normal file
110
pkg/types/telemetrytypes/selector.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const wildcardSelector = "*"
|
||||
|
||||
var telemetryGrantQueryTypes = map[string]bool{
|
||||
"builder_query": true,
|
||||
"builder_sub_query": true,
|
||||
"promql": false,
|
||||
"clickhouse_sql": false,
|
||||
}
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"signoz.workspace.key.id": {},
|
||||
}
|
||||
|
||||
func NewTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != FieldContextUnspecified && fieldKey.FieldContext != FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelector(input string) (string, error) {
|
||||
if input == wildcardSelector {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, "/", 3)
|
||||
|
||||
keyScoped, ok := telemetryGrantQueryTypes[parts[0]]
|
||||
if !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must start with a supported query type or be %q", input, wildcardSelector)
|
||||
}
|
||||
queryType := parts[0]
|
||||
|
||||
if len(parts) < 3 {
|
||||
if len(parts) == 2 && parts[1] != wildcardSelector {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must be <query_type>, <query_type>/*, <query_type>/<key>/* or <query_type>/<key>/<value>", input)
|
||||
}
|
||||
return queryType + "/" + wildcardSelector, nil
|
||||
}
|
||||
|
||||
if !keyScoped {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q is invalid: query type %q supports only %q or %q", input, queryType, queryType, queryType+"/"+wildcardSelector)
|
||||
}
|
||||
|
||||
key, ok := NewTelemetryGrantKey(parts[1])
|
||||
if !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a supported key: %s", input, strings.Join(telemetryGrantKeyNames(), ", "))
|
||||
}
|
||||
|
||||
value := parts[2]
|
||||
if value == wildcardSelector {
|
||||
return queryType + "/" + key + "/" + wildcardSelector, nil
|
||||
}
|
||||
if value == "" || strings.HasPrefix(value, "$") {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a concrete non-empty value", input)
|
||||
}
|
||||
|
||||
return queryType + "/" + key + "/" + value, nil
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelectors(selector string) []string {
|
||||
if selector == wildcardSelector {
|
||||
return []string{wildcardSelector}
|
||||
}
|
||||
|
||||
parts := strings.SplitN(selector, "/", 3)
|
||||
queryType := parts[0]
|
||||
|
||||
if len(parts) < 3 {
|
||||
return []string{queryType + "/" + wildcardSelector, wildcardSelector}
|
||||
}
|
||||
|
||||
key, value := parts[1], parts[2]
|
||||
if value == wildcardSelector {
|
||||
return []string{
|
||||
queryType + "/" + key + "/" + wildcardSelector,
|
||||
queryType + "/" + wildcardSelector,
|
||||
wildcardSelector,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
queryType + "/" + key + "/" + value,
|
||||
queryType + "/" + key + "/" + wildcardSelector,
|
||||
queryType + "/" + wildcardSelector,
|
||||
wildcardSelector,
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryGrantKeyNames() []string {
|
||||
names := make([]string, 0, len(telemetryGrantKeys))
|
||||
for name := range telemetryGrantKeys {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
78
pkg/types/telemetrytypes/selector_test.go
Normal file
78
pkg/types/telemetrytypes/selector_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTelemetryGrantSelector(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"*": "*",
|
||||
"builder_query": "builder_query/*",
|
||||
"builder_query/*": "builder_query/*",
|
||||
"promql": "promql/*",
|
||||
"clickhouse_sql": "clickhouse_sql/*",
|
||||
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
|
||||
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
|
||||
}
|
||||
for input, expected := range valid {
|
||||
canonical, err := NewTelemetryGrantSelector(input)
|
||||
require.NoError(t, err, "input %q", input)
|
||||
assert.Equal(t, expected, canonical, "input %q", input)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"key-a",
|
||||
"signoz.workspace.key.id = 'key-a'",
|
||||
"builder_trace_operator/signoz.workspace.key.id/key-a",
|
||||
"builder_query/service.name/frontend",
|
||||
"builder_query/signoz.workspace.key.id/",
|
||||
"builder_query/signoz.workspace.key.id/$svc",
|
||||
"*/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id",
|
||||
"clickhouse_sql/signoz.workspace.key.id/key-a",
|
||||
"clickhouse_sql/signoz.workspace.key.id/*",
|
||||
"promql/signoz.workspace.key.id/key-a",
|
||||
}
|
||||
for _, input := range invalid {
|
||||
_, err := NewTelemetryGrantSelector(input)
|
||||
assert.Error(t, err, "input %q", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelemetryGrantKey(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
}
|
||||
for keyText, expected := range valid {
|
||||
key, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.True(t, ok, keyText)
|
||||
assert.Equal(t, expected, key, keyText)
|
||||
}
|
||||
|
||||
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
|
||||
_, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.False(t, ok, keyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelemetryGrantSelectors(t *testing.T) {
|
||||
ladders := map[string][]string{
|
||||
"*": {"*"},
|
||||
"builder_query/*": {"builder_query/*", "*"},
|
||||
"promql/*": {"promql/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/*": {"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/a": {"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/a/b": {"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
}
|
||||
for selector, expected := range ladders {
|
||||
assert.Equal(t, expected, NewTelemetryGrantSelectors(selector), "selector %q", selector)
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,6 @@ func (Signal) Enum() []any {
|
||||
SignalTraces,
|
||||
SignalLogs,
|
||||
SignalMetrics,
|
||||
SignalUnspecified,
|
||||
}
|
||||
}
|
||||
|
||||
36
tests/e2e/helpers/query-builder.ts
Normal file
36
tests/e2e/helpers/query-builder.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
const EDITOR_CONTENT = '.query-where-clause-editor .cm-content';
|
||||
const SPANS = '.query-where-clause-editor .cm-line span';
|
||||
|
||||
export async function typeExpression(page: Page, expr: string): Promise<void> {
|
||||
const editor = page.locator(EDITOR_CONTENT).first();
|
||||
await editor.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await editor.click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.type(expr);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
export async function tokenColor(
|
||||
page: Page,
|
||||
text: string,
|
||||
): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
({ selector, target }) => {
|
||||
const el = Array.from(
|
||||
document.querySelectorAll<HTMLSpanElement>(selector),
|
||||
).find((s) => s.textContent === target);
|
||||
return el ? window.getComputedStyle(el).color : null;
|
||||
},
|
||||
{ selector: SPANS, target: text },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setupTheme(
|
||||
page: Page,
|
||||
theme: 'dark' | 'light',
|
||||
): Promise<void> {
|
||||
await page.addInitScript((t) => localStorage.setItem('THEME', t), theme);
|
||||
}
|
||||
66
tests/e2e/tests/query-builder/token-highlight.spec.ts
Normal file
66
tests/e2e/tests/query-builder/token-highlight.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { setupTheme, tokenColor, typeExpression } from '@helpers/query-builder';
|
||||
|
||||
const LOGS_EXPLORER = '/logs/logs-explorer';
|
||||
|
||||
type ThemeConfig = {
|
||||
name: string;
|
||||
theme: 'dark' | 'light';
|
||||
colors: {
|
||||
identifier: string;
|
||||
operator: string;
|
||||
property: string;
|
||||
string: string;
|
||||
};
|
||||
};
|
||||
|
||||
const THEMES: ThemeConfig[] = [
|
||||
{
|
||||
name: 'dark (copilot)',
|
||||
theme: 'dark',
|
||||
colors: {
|
||||
identifier: 'rgb(147, 157, 165)',
|
||||
operator: 'rgb(186, 142, 247)',
|
||||
property: 'rgb(255, 234, 107)',
|
||||
string: 'rgb(91, 236, 149)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'light (githubLight)',
|
||||
theme: 'light',
|
||||
colors: {
|
||||
identifier: 'rgb(0, 92, 197)',
|
||||
operator: 'rgb(0, 92, 197)',
|
||||
property: 'rgb(111, 66, 193)',
|
||||
string: 'rgb(3, 47, 98)',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, theme, colors } of THEMES) {
|
||||
test.describe(`QueryBuilder token highlighting — ${name}`, () => {
|
||||
test(`k8s.namespace.name — each segment gets its own color`, async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await setupTheme(page, theme);
|
||||
await page.goto(LOGS_EXPLORER);
|
||||
await typeExpression(page, 'k8s.namespace.name');
|
||||
|
||||
expect(await tokenColor(page, 'k8s')).toBe(colors.identifier);
|
||||
expect(await tokenColor(page, '.')).toBe(colors.operator);
|
||||
expect(await tokenColor(page, 'namespace')).toBe(colors.property);
|
||||
expect(await tokenColor(page, 'name')).toBe(colors.property);
|
||||
});
|
||||
|
||||
test(`['value', 'value2'] — string tokens have native theme color`, async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await setupTheme(page, theme);
|
||||
await page.goto(LOGS_EXPLORER);
|
||||
await typeExpression(page, "['value', 'value2']");
|
||||
|
||||
expect(await tokenColor(page, "'value'")).toBe(colors.string);
|
||||
expect(await tokenColor(page, "'value2'")).toBe(colors.string);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user