Compare commits

..

2 Commits

Author SHA1 Message Date
nityanandagohain
2a50f46387 Merge remote-tracking branch 'origin/main' into feat/test_endpoint_attributemapping 2026-06-17 21:27:15 +05:30
Pradeep Kumar
0e4f5d1c4e feat(spanmapper): add preview endpoint for span attribute mapping
Adds POST /api/v1/span_mapper_groups/preview so users can see what their
attribute mappings will do to a span before/while configuring them.

You send a sample input either a set of attributes, a single OTLP
span, or a full OTLP trace and get back the transformed result in the
same form. By default it runs against all the org's enabled mappings;
pass groupId to scope it to one group.

The actual signozspanmapper collector processor not merged yet
https://github.com/SigNoz/signoz-otel-collector/pull/796
2026-06-15 16:39:01 +05:30
79 changed files with 1766 additions and 3397 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.129.0
image: signoz/signoz:v0.128.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.129.0
image: signoz/signoz:v0.128.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.129.0}
image: signoz/signoz:${VERSION:-v0.128.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.129.0}
image: signoz/signoz:${VERSION:-v0.128.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -7240,6 +7240,41 @@ components:
- operation
- priority
type: object
SpantypesSpanMappingPreviewGroup:
properties:
group:
$ref: '#/components/schemas/SpantypesPostableSpanMapperGroup'
mappers:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
nullable: true
type: array
required:
- group
- mappers
type: object
SpantypesSpanMappingPreviewRequest:
properties:
groupId:
nullable: true
type: string
groups:
items:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewGroup'
nullable: true
type: array
span:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesSpanMappingPreviewResponse:
properties:
span:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -12797,6 +12832,69 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/preview:
post:
deprecated: false
description: Previews how attribute mappings would transform a sample span.
operationId: PreviewSpanMapping
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Preview span attribute mapping against a sample span
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false

View File

@@ -8492,6 +8492,56 @@ export interface SpantypesSpanMapperDTO {
updatedBy?: string;
}
export interface SpantypesSpanMappingPreviewGroupDTO {
group: SpantypesPostableSpanMapperGroupDTO;
/**
* @type array,null
*/
mappers: SpantypesPostableSpanMapperDTO[] | null;
}
export type SpantypesSpanMappingPreviewRequestDTOSpanAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewRequestDTOSpan =
SpantypesSpanMappingPreviewRequestDTOSpanAnyOf | null;
export interface SpantypesSpanMappingPreviewRequestDTO {
/**
* @type string,null
*/
groupId?: string | null;
/**
* @type array,null
*/
groups?: SpantypesSpanMappingPreviewGroupDTO[] | null;
/**
* @type object,null
*/
span?: SpantypesSpanMappingPreviewRequestDTOSpan;
}
export type SpantypesSpanMappingPreviewResponseDTOSpanAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewResponseDTOSpan =
SpantypesSpanMappingPreviewResponseDTOSpanAnyOf | null;
export interface SpantypesSpanMappingPreviewResponseDTO {
/**
* @type object,null
*/
span?: SpantypesSpanMappingPreviewResponseDTOSpan;
}
export interface SpantypesUpdatableSpanMapperDTO {
config?: SpantypesSpanMapperConfigDTO;
/**
@@ -9798,6 +9848,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type PreviewSpanMapping200 = {
data: SpantypesSpanMappingPreviewResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {

View File

@@ -27,9 +27,11 @@ import type {
ListSpanMapperGroupsParams,
ListSpanMappers200,
ListSpanMappersPathParameters,
PreviewSpanMapping200,
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesSpanMappingPreviewRequestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
UpdateSpanMapperGroupPathParameters,
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Previews how attribute mappings would transform a sample span.
* @summary Preview span attribute mapping against a sample span
*/
export const previewSpanMapping = (
spantypesSpanMappingPreviewRequestDTO?: BodyType<SpantypesSpanMappingPreviewRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PreviewSpanMapping200>({
url: `/api/v1/span_mapper_groups/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesSpanMappingPreviewRequestDTO,
signal,
});
};
export const getPreviewSpanMappingMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
> => {
const mutationKey = ['previewSpanMapping'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof previewSpanMapping>>,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return previewSpanMapping(data);
};
return { mutationFn, ...mutationOptions };
};
export type PreviewSpanMappingMutationResult = NonNullable<
Awaited<ReturnType<typeof previewSpanMapping>>
>;
export type PreviewSpanMappingMutationBody =
| BodyType<SpantypesSpanMappingPreviewRequestDTO>
| undefined;
export type PreviewSpanMappingMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Preview span attribute mapping against a sample span
*/
export const usePreviewSpanMapping = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
> => {
return useMutation(getPreviewSpanMappingMutationOptions(options));
};

View File

@@ -20,7 +20,6 @@ import APIError from 'types/api/error';
import DashboardActions from './DashboardActions/DashboardActions';
import DashboardInfo from './DashboardInfo/DashboardInfo';
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
import VariablesBar from '../VariablesBar/VariablesBar';
import styles from './DashboardPageToolbar.module.scss';
@@ -138,8 +137,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
onOpenRename={startEdit}
/>
</div>
<VariablesBar dashboard={dashboard} />
</section>
);
}

View File

@@ -1,15 +1,106 @@
// Publish tab — "status strip" direction (Claude Design: Publish Drawer Final).
// Fills the drawer height so the actions anchor a footer instead of floating.
.publishTab {
// settings card wrapper — mirrors the V1 public dashboard treatment
.publicDashboardCard {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
gap: 8px;
padding: 16px;
border-radius: 3px;
border: 1px solid var(--l2-border);
}
.content {
flex: 1;
.statusTitle {
margin-bottom: 16px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.checkbox {
margin-bottom: 8px;
}
.timeRangeSelectGroup {
display: flex;
flex-direction: column;
gap: 20px;
gap: 4px;
margin-bottom: 8px;
}
.timeRangeSelectLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.timeRangeSelect {
width: 200px;
}
.urlGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.urlLabel {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.urlContainer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.urlText {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
line-height: 32px;
}
.callout {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px 8px;
border-radius: 3px;
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
.calloutIcon {
flex-shrink: 0;
color: var(--text-robin-300);
}
.calloutText {
color: var(--text-robin-300);
font-family: Inter;
font-size: 11px;
font-weight: 400;
line-height: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 32px;
}

View File

@@ -1,7 +1,7 @@
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
import { Globe, Trash } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './PublicDashboardActions.module.scss';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardActionsProps {
isPublic: boolean;
@@ -25,7 +25,7 @@ function PublicDashboardActions({
onUnpublish,
}: PublicDashboardActionsProps): JSX.Element {
return (
<div className={styles.footer}>
<div className={styles.actions}>
{isPublic ? (
<>
<Button
@@ -33,22 +33,22 @@ function PublicDashboardActions({
color="destructive"
disabled={disabled}
loading={isUnpublishing}
prefix={<Trash size={15} />}
prefix={<Trash size={14} />}
testId="public-dashboard-unpublish"
onClick={onUnpublish}
>
Unpublish Dashboard
Unpublish dashboard
</Button>
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isUpdating}
prefix={<RefreshCw size={15} />}
prefix={<Globe size={14} />}
testId="public-dashboard-update"
onClick={onUpdate}
>
Update Dashboard
Update published dashboard
</Button>
</>
) : (
@@ -57,11 +57,11 @@ function PublicDashboardActions({
color="primary"
disabled={disabled}
loading={isPublishing}
prefix={<Globe size={15} />}
prefix={<Globe size={14} />}
testId="public-dashboard-publish"
onClick={onPublish}
>
Publish Dashboard
Publish dashboard
</Button>
)}
</div>

View File

@@ -1,12 +0,0 @@
.footer {
position: sticky;
z-index: 1;
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 10px;
padding-top: 14px;
border-top: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,17 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
function PublicDashboardCallout(): JSX.Element {
return (
<div className={styles.callout}>
<Info size={12} className={styles.calloutIcon} />
<Typography.Text className={styles.calloutText}>
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
);
}
export default PublicDashboardCallout;

View File

@@ -1,19 +0,0 @@
.hint {
display: flex;
align-items: flex-start;
gap: 8px;
padding-top: 2px;
color: var(--l3-foreground);
}
.hintIcon {
flex: none;
margin-top: 1px;
color: var(--l3-foreground);
}
.hintText {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.5;
}

View File

@@ -1,17 +0,0 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboardHint.module.scss';
function PublicDashboardHint(): JSX.Element {
return (
<div className={styles.hint}>
<Info size={14} className={styles.hintIcon} />
<Typography.Text className={styles.hintText}>
Dashboard variables aren&apos;t supported on public links.
</Typography.Text>
</div>
);
}
export default PublicDashboardHint;

View File

@@ -1,9 +1,9 @@
import { Checkbox } from '@signozhq/ui/checkbox';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import styles from './PublicDashboardSettingsForm.module.scss';
import { TIME_RANGE_PRESETS_OPTIONS } from './constants';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardSettingsFormProps {
timeRangeEnabled: boolean;
@@ -22,29 +22,28 @@ function PublicDashboardSettingsForm({
}: PublicDashboardSettingsFormProps): JSX.Element {
return (
<>
<div className={styles.switchRow}>
<Switch
testId="public-dashboard-time-range-toggle"
value={timeRangeEnabled}
disabled={disabled}
onChange={onTimeRangeEnabledChange}
>
Enable time range
</Switch>
</div>
<Checkbox
id="public-dashboard-enable-time-range"
className={styles.checkbox}
testId="public-dashboard-time-range-toggle"
value={timeRangeEnabled}
disabled={disabled}
onChange={(checked): void => onTimeRangeEnabledChange(checked === true)}
>
Enable time range
</Checkbox>
<div className={styles.fieldGroup}>
<Typography.Text className={styles.fieldLabel}>
<div className={styles.timeRangeSelectGroup}>
<Typography.Text className={styles.timeRangeSelectLabel}>
Default time range
</Typography.Text>
<SelectSimple
className={styles.timeRangeSelect}
testId="public-dashboard-default-time-range"
placeholder="Select default time range"
items={RelativeDurationOptions}
items={TIME_RANGE_PRESETS_OPTIONS}
value={defaultTimeRange}
disabled={disabled}
withPortal={false}
onChange={(value): void => onDefaultTimeRangeChange(value as string)}
/>
</div>

View File

@@ -1,34 +0,0 @@
.switchRow {
display: flex;
align-items: center;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: 8px;
// Render the (non-portaled) dropdown above the drawer.
[data-radix-popper-content-wrapper] {
z-index: 1100 !important;
}
// Radix sets --radix-select-trigger-width on the content element (the wrapper's
// child), so match it there to make the dropdown take the input's width.
// SelectSimple exposes no content className, hence the descendant selector.
[data-radix-popper-content-wrapper] > * {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.timeRangeSelect {
width: 100%;
}

View File

@@ -0,0 +1,21 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardStatusProps {
isPublic: boolean;
}
function PublicDashboardStatus({
isPublic,
}: PublicDashboardStatusProps): JSX.Element {
return (
<Typography.Text className={styles.statusTitle}>
{isPublic
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Text>
);
}
export default PublicDashboardStatus;

View File

@@ -1,67 +0,0 @@
.statusStrip {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--l2-border);
background: var(--l1-background);
}
.statusStripLive {
border-color: var(--callout-primary-border);
background: var(--callout-primary-background);
}
.statusMedallion {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 6px;
border: 1px solid var(--l2-border);
background: var(--l3-background);
color: var(--l2-foreground);
}
.statusMedallionLive {
border-color: var(--callout-primary-border);
background: var(--callout-primary-background);
color: var(--callout-primary-icon);
}
.statusBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.statusTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
.statusSubtitle {
margin-top: 2px;
color: var(--l3-foreground);
font-size: 13px;
line-height: 1.35;
}
.statusSubtitleLive {
color: var(--l2-foreground);
}
.statusBadgeDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
background: currentColor;
}

View File

@@ -1,50 +0,0 @@
import { Globe, LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './PublicDashboardStatus.module.scss';
interface PublicDashboardStatusProps {
isPublic: boolean;
}
function PublicDashboardStatus({
isPublic,
}: PublicDashboardStatusProps): JSX.Element {
return (
<div
className={cx(styles.statusStrip, { [styles.statusStripLive]: isPublic })}
>
<span
className={cx(styles.statusMedallion, {
[styles.statusMedallionLive]: isPublic,
})}
>
{isPublic ? <Globe size={18} /> : <LockKeyhole size={18} />}
</span>
<div className={styles.statusBody}>
<Typography.Text className={styles.statusTitle}>
{isPublic ? 'This dashboard is live' : 'This dashboard is private'}
</Typography.Text>
<Typography.Text
className={cx(styles.statusSubtitle, {
[styles.statusSubtitleLive]: isPublic,
})}
>
{isPublic
? 'Anyone with the link can view it — no account needed.'
: 'Publish it to share a read-only view with anyone who has the link.'}
</Typography.Text>
</div>
<Badge variant="outline" color={isPublic ? 'robin' : 'secondary'}>
<span className={styles.statusBadgeDot} />
{isPublic ? 'Public' : 'Private'}
</Badge>
</div>
);
}
export default PublicDashboardStatus;

View File

@@ -0,0 +1,49 @@
import { Copy, ExternalLink } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboard.module.scss';
interface PublicDashboardUrlProps {
url: string;
onCopy: () => void;
onOpen: () => void;
}
function PublicDashboardUrl({
url,
onCopy,
onOpen,
}: PublicDashboardUrlProps): JSX.Element {
return (
<div className={styles.urlGroup}>
<Typography.Text className={styles.urlLabel}>
Public dashboard URL
</Typography.Text>
<div className={styles.urlContainer}>
<Typography.Text className={styles.urlText}>{url}</Typography.Text>
<Button
variant="ghost"
size="icon"
aria-label="Copy public dashboard URL"
testId="public-dashboard-copy-url"
onClick={onCopy}
>
<Copy size={14} />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open public dashboard in new tab"
testId="public-dashboard-open-url"
onClick={onOpen}
>
<ExternalLink size={14} />
</Button>
</div>
</div>
);
}
export default PublicDashboardUrl;

View File

@@ -1,69 +0,0 @@
.fieldGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.linkPlaceholder {
display: flex;
align-items: center;
gap: 9px;
height: 40px;
padding: 0 12px;
border-radius: 6px;
border: 1px dashed var(--l2-border);
background: var(--l1-background);
color: var(--l3-foreground);
}
.linkPlaceholderIcon {
flex: none;
color: var(--l3-foreground);
}
.linkPlaceholderText {
color: var(--l3-foreground);
font-size: 13px;
line-height: 1;
}
.linkField {
display: flex;
align-items: center;
gap: 2px;
height: 40px;
padding: 0 5px 0 12px;
border-radius: 6px;
border: 1px solid var(--l2-border);
background: var(--l1-background);
&:hover {
border-color: var(--l3-border);
}
}
.linkUrl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--l2-foreground);
font-family: var(--font-mono, 'Geist Mono'), monospace;
font-size: 13px;
line-height: 1;
}
.linkDivider {
width: 1px;
height: 20px;
margin: 0 4px;
background: var(--l2-border);
}

View File

@@ -1,59 +0,0 @@
import { Copy, ExternalLink, Link2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './PublicDashboardUrl.module.scss';
interface PublicDashboardUrlProps {
isPublic: boolean;
url: string;
onCopy: () => void;
onOpen: () => void;
}
function PublicDashboardUrl({
isPublic,
url,
onCopy,
onOpen,
}: PublicDashboardUrlProps): JSX.Element {
return (
<div className={styles.fieldGroup}>
<Typography.Text className={styles.fieldLabel}>Public link</Typography.Text>
{isPublic ? (
<div className={styles.linkField}>
<Typography.Text className={styles.linkUrl}>{url}</Typography.Text>
<span className={styles.linkDivider} />
<Button
variant="ghost"
size="icon"
aria-label="Copy link"
testId="public-dashboard-copy-url"
onClick={onCopy}
>
<Copy size={15} />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Open link"
testId="public-dashboard-open-url"
onClick={onOpen}
>
<ExternalLink size={15} />
</Button>
</div>
) : (
<div className={styles.linkPlaceholder}>
<Link2 size={15} className={styles.linkPlaceholderIcon} />
<Typography.Text className={styles.linkPlaceholderText}>
Your shareable link will appear here once published
</Typography.Text>
</div>
)}
</div>
);
}
export default PublicDashboardUrl;

View File

@@ -0,0 +1,14 @@
export interface TimeRangePresetOption {
label: string;
value: string;
}
// Default time-range presets offered for the public dashboard viewer.
export const TIME_RANGE_PRESETS_OPTIONS: TimeRangePresetOption[] = [
{ label: 'Last 5 minutes', value: '5m' },
{ label: 'Last 15 minutes', value: '15m' },
{ label: 'Last 30 minutes', value: '30m' },
{ label: 'Last 1 hour', value: '1h' },
{ label: 'Last 6 hours', value: '6h' },
{ label: 'Last 1 day', value: '24h' },
];

View File

@@ -1,10 +1,10 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import PublicDashboardActions from './PublicDashboardActions/PublicDashboardActions';
import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
import PublicDashboardActions from './PublicDashboardActions';
import PublicDashboardCallout from './PublicDashboardCallout';
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm';
import PublicDashboardStatus from './PublicDashboardStatus';
import PublicDashboardUrl from './PublicDashboardUrl';
import { usePublicDashboard } from './usePublicDashboard';
import styles from './PublicDashboard.module.scss';
@@ -37,27 +37,22 @@ function PublicDashboardSettings({
const controlsDisabled = isLoading || !isAdmin;
return (
<div className={styles.publishTab}>
<div className={styles.content}>
<PublicDashboardStatus isPublic={isPublic} />
<div className={styles.publicDashboardCard}>
<PublicDashboardStatus isPublic={isPublic} />
<PublicDashboardUrl
isPublic={isPublic}
url={publicUrl}
onCopy={onCopyUrl}
onOpen={onOpenUrl}
/>
<PublicDashboardSettingsForm
timeRangeEnabled={timeRangeEnabled}
defaultTimeRange={defaultTimeRange}
disabled={controlsDisabled}
onTimeRangeEnabledChange={setTimeRangeEnabled}
onDefaultTimeRangeChange={setDefaultTimeRange}
/>
<PublicDashboardSettingsForm
timeRangeEnabled={timeRangeEnabled}
defaultTimeRange={defaultTimeRange}
disabled={controlsDisabled}
onTimeRangeEnabledChange={setTimeRangeEnabled}
onDefaultTimeRangeChange={setDefaultTimeRange}
/>
</div>
{isPublic && (
<PublicDashboardUrl url={publicUrl} onCopy={onCopyUrl} onOpen={onOpenUrl} />
)}
<PublicDashboardHint />
<PublicDashboardCallout />
<PublicDashboardActions
isPublic={isPublic}

View File

@@ -6,6 +6,7 @@ import {
invalidateGetPublicDashboard,
useCreatePublicDashboard,
useDeletePublicDashboard,
useGetPublicDashboard,
useUpdatePublicDashboard,
} from 'api/generated/services/dashboard';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
@@ -16,8 +17,6 @@ import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import { usePublicDashboardMeta } from './usePublicDashboardMeta';
export interface UsePublicDashboardReturn {
isPublic: boolean;
isAdmin: boolean;
@@ -55,16 +54,22 @@ export function usePublicDashboard(
const [defaultTimeRange, setDefaultTimeRange] =
useState<string>(DEFAULT_TIME_RANGE);
// Read the shared public-meta cache — the GET is owned globally (toolbar), so the
// drawer reuses it rather than issuing its own request.
const {
publicMeta,
isPublic,
data,
isLoading: isLoadingMeta,
isFetching,
error,
refetch,
} = usePublicDashboardMeta(dashboardId);
} = useGetPublicDashboard(
{ id: dashboardId },
{ query: { enabled: !!dashboardId, retry: false } },
);
// react-query retains the last successful `data` even after a refetch errors, so
// after unpublishing (the refetch 404s) `data` still holds the old publicPath.
// Gate on `!error` so the UI flips back to the private state.
const publicMeta = error ? undefined : data?.data;
const isPublic = !!publicMeta?.publicPath;
// Seed form state from the server config when published.
useEffect(() => {
@@ -98,7 +103,7 @@ export function usePublicDashboard(
(message: string): void => {
toast.success(message);
void invalidateGetPublicDashboard(queryClient, { id: dashboardId });
refetch();
void refetch();
},
[queryClient, dashboardId, refetch],
);

View File

@@ -1,65 +0,0 @@
import { useMemo } from 'react';
import { useGetPublicDashboard } from 'api/generated/services/dashboard';
import type { DashboardtypesGettablePublicDasbhboardDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
export interface UsePublicDashboardMetaReturn {
publicMeta: DashboardtypesGettablePublicDasbhboardDTO | undefined;
isPublic: boolean;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => void;
}
// How long a fetched result stays fresh before a natural trigger may refresh it.
const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
/**
* Single source of truth for a dashboard's public-sharing meta. Keyed by dashboard
* id via the generated query, so the GET happens once globally (the toolbar mounts it
* with the dashboard) and every other caller — the publish settings drawer — reads the
* same cache instead of issuing its own request. A mutation that invalidates
* getGetPublicDashboardQueryKey refreshes all consumers at once.
*
* Only fetched on cloud / enterprise tenants, where public dashboards are available.
*/
export function usePublicDashboardMeta(
dashboardId: string,
): UsePublicDashboardMetaReturn {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
{ id: dashboardId },
{
query: {
enabled,
retry: false,
// refetchOnMount: false stops opening the drawer / switching to the Publish
// tab from refiring the GET — it reuses the toolbar's cached result. A finite
// staleTime still lets it refresh naturally once the data ages, and mutations
// invalidate the key to refresh the published state immediately.
staleTime: PUBLIC_META_STALE_TIME,
refetchOnMount: false,
},
},
);
// react-query retains the last successful `data` after a refetch errors (e.g. the
// 404 once a dashboard is unpublished), so gate on the error to reflect the
// private state.
const publicMeta = error ? undefined : data?.data;
return useMemo(
() => ({
publicMeta,
isPublic: !!publicMeta?.publicPath,
isLoading,
isFetching,
error,
refetch,
}),
[publicMeta, isLoading, isFetching, error, refetch],
);
}

View File

@@ -1,34 +1,24 @@
import { useEffect, useMemo, useState } from 'react';
import { Info } from '@signozhq/icons';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import TextToolTip from 'components/TextToolTip';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { isRetryableError } from 'utils/errorUtils';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
} from '../variableFormModel';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: DynamicSignalOption;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: DynamicSignalOption;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
attributeError?: string;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
@@ -37,24 +27,18 @@ function DynamicVariableFields({
signal,
onChange,
onPreview,
attributeError,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const apiSignal = signalForApi(signal);
const {
data: keyData,
isLoading,
error,
refetch,
} = useGetFieldKeys({
signal: apiSignal,
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// CustomSelect filters the supplied options locally as the user types.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
@@ -65,7 +49,7 @@ function DynamicVariableFields({
);
const { data: valueData } = useGetFieldValues({
signal: apiSignal,
signal,
name: attribute,
enabled: !!attribute,
});
@@ -78,60 +62,40 @@ function DynamicVariableFields({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
const errorMessage = error ? (error as Error).message || null : null;
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
<TextToolTip
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<Select
<SelectSimple
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={signal}
options={DYNAMIC_SIGNALS.map((s) => ({
label: DYNAMIC_SIGNAL_LABEL[s],
value: s,
}))}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
onChange({ dynamicSignal: value as TelemetrySignal })
}
data-testid="variable-signal-select"
testId="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<CustomSelect
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
options={options}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
noDataMessage="No fields found"
errorMessage={errorMessage}
onRetry={(): void => {
void refetch();
}}
showRetryButton={error ? isRetryableError(error) : true}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
{attributeError ? (
<Typography.Text className={styles.errorText}>
{attributeError}
</Typography.Text>
) : null}
</>
);
}

View File

@@ -1,139 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
import { Select } from 'antd';
import { CustomSelect } from 'components/NewSelect';
import {
VARIABLE_SORT_LABEL,
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface ListVariableFieldsProps {
model: VariableFormModel;
onChange: (patch: Partial<VariableFormModel>) => void;
previewValues: (string | number)[];
previewError: string | null;
defaultValue: string;
onDefaultValueChange: (value: string) => void;
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
showAllOptionField: boolean;
}
/**
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
* preview, sort, multi-select / ALL toggles and the default-value picker.
*/
function ListVariableFields({
model,
onChange,
previewValues,
previewError,
defaultValue,
onDefaultValueChange,
showAllOptionField,
}: ListVariableFieldsProps): JSX.Element {
return (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<Select
className={styles.sortSelect}
popupMatchSelectWidth={false}
value={model.sort}
options={VARIABLE_SORTS.map((sort) => ({
label: VARIABLE_SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => onChange({ sort: value as VariableSort })}
data-testid="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void =>
onChange({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
})
}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => onChange({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Default Value</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<CustomSelect
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
);
}
export default ListVariableFields;

View File

@@ -3,14 +3,14 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
variables: PayloadVariables;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
@@ -19,7 +19,7 @@ interface QueryVariableFieldsProps {
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
variables,
sort,
onChange,
onPreview,
onError,
@@ -30,21 +30,20 @@ function QueryVariableFields({
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({ query: queryValue, variables });
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(res.payload.variableValues ?? []);
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
const detail = (err as { details?: { error?: string } }).details?.error;
const message =
detail && detail.includes('Syntax error:')
? 'Please make sure query is valid and dependent variables are selected'
: detail || (err as Error).message || 'Failed to run query';
onError(message);
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);

View File

@@ -5,8 +5,22 @@
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
border: 1px solid var(--l2-border);
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
}
.content {
@@ -28,12 +42,6 @@
width: 200px;
}
.sourceLabel {
display: flex;
align-items: center;
gap: 6px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
@@ -51,7 +59,7 @@
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
@@ -70,89 +78,48 @@
color: var(--bg-amber-500);
}
/* Variable type — Tabs root composing the picker row + per-type body panels. */
/* Variable type segmented group */
.typeSection {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 40px;
margin-bottom: 0;
}
/* Picker row (label left, tabs right); the bottom divider separates type from
config. Single line — the tab row scrolls (never wraps) when narrow. */
.typePicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--l2-border);
@media (max-width: 1440px) {
flex-wrap: wrap;
}
}
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
.typePanel {
padding: 0 !important;
}
.typeContent {
display: flex;
flex-direction: column;
gap: 20px;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
width: auto;
}
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
is hidden — the row stays a single crisp line and scrolls only when narrow. */
.typeTabsScroll {
justify-self: flex-end;
--tab-list-wrapper-secondary-padding-left: 0;
}
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
padding, segments divided by 1px borders, active segment filled + bold. */
.typeTabs {
display: inline-flex;
flex-wrap: nowrap;
width: max-content;
gap: 0;
padding: 0;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: transparent;
}
.typeTab {
display: inline-flex;
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
}
.typeBtn {
--button-height: 32px;
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding: 6px 14px;
white-space: nowrap;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
&:not(:last-child) {
border-right: 1px solid var(--l2-border);
& + & {
border-left: 1px solid var(--l1-border);
}
}
&[data-state='active'] {
color: var(--l1-foreground);
font-weight: 500;
// override the Tabs component's default (transparent) active background.
background: var(--l3-background) !important;
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
@@ -171,7 +138,7 @@
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 2px;
}
@@ -187,7 +154,7 @@
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
@@ -241,7 +208,7 @@
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l2-border);
border: 1px solid var(--l1-border);
border-radius: 3px;
}
@@ -304,9 +271,13 @@
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
@@ -326,21 +297,14 @@
letter-spacing: -0.06px;
}
/* All variable selects (Source / Attribute / Sort / Default Value) share width
and a consistent --l2-border outline. */
.sortSelect,
.searchSelect {
width: 240px;
flex-shrink: 0;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
width: 100%;
}
.actionButtons {
width: 100%;
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -1,199 +1,350 @@
import { Check, X } from '@signozhq/icons';
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TabsContent, TabsRoot } from '@signozhq/ui/tabs';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableType } from '../variableFormModel';
import {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
import ListVariableFields from './ListVariableFields';
import QueryVariableFields from './QueryVariableFields';
import { useVariableForm } from './useVariableForm';
import VariableTypeTabs from './VariableTypeTabs';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
import BackToAllVariables from '../components/BackToAllVariables/BackToAllVariables';
import { VariableFormProps } from '../types';
import VariableInfoForm from '../components/VariableInfoForm/VariableInfoForm';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list. Form
* state/handlers live in {@link useVariableForm}; the shared list-type rows in
* {@link ListVariableFields}.
* and searchable selects). Master→detail: renders in place of the list.
*/
function VariableForm({
initial,
siblings,
isNew,
existingNames,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
} = useVariableForm({ initial, siblings, isNew, onSave });
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
// Shared list rows (preview/sort/multi/default) for the list-type variables;
// rendered as a sibling inside each list-type panel. Only the active panel
// mounts (Tabs unmounts the rest), so reusing one element is safe.
const listFields = isListType ? (
<ListVariableFields
model={model}
onChange={set}
previewValues={previewValues}
previewError={previewError}
defaultValue={defaultValue}
onDefaultValueChange={setDefaultValue}
showAllOptionField={showAllOptionField}
/>
) : null;
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
return (
<div className={styles.container}>
<BackToAllVariables onClose={onClose} />
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.content}>
<VariableInfoForm
title={model.name}
description={model.description}
onTitleChange={onNameChange}
onDescriptionChange={(value): void => set({ description: value })}
visibleNameError={visibleNameError}
/>
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
<TabsRoot
className={styles.typeSection}
value={model.type}
onValueChange={(next): void => selectType(next as VariableType)}
>
<VariableTypeTabs />
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
<TabsContent value="DYNAMIC" className={styles.typePanel}>
<div className={styles.typeContent}>
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={onDynamicChange}
onPreview={setRawPreview}
attributeError={attributeError}
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
{listFields}
</div>
</TabsContent>
) : null}
<TabsContent value="QUERY" className={styles.typePanel}>
<div className={styles.typeContent}>
<QueryVariableFields
queryValue={model.queryValue}
variables={payloadVariables}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setRawPreview}
onError={setPreviewError}
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
{listFields}
</div>
</TabsContent>
) : null}
<TabsContent value="CUSTOM" className={styles.typePanel}>
<div className={styles.typeContent}>
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
testId="variable-sort-select"
/>
</div>
{listFields}
</div>
</TabsContent>
<TabsContent value="TEXT" className={styles.typePanel}>
<div className={styles.typeContent}>
<div className={cx(styles.row, styles.textboxSection)}>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</div>
</TabsContent>
</TabsRoot>
{cycleError ? (
<Typography.Text className={styles.errorText}>
{cycleError}
</Typography.Text>
) : null}
<div className={styles.actionButtons}>
<Button
variant="outlined"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError || !!attributeError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</>
) : null}
</div>
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,99 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -1,93 +0,0 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import TextToolTip from 'components/TextToolTip';
import styles from './VariableForm.module.scss';
/**
* Presentational trigger row for the variable-type tabs (label + segmented
* triggers). Must render inside a `TabsRoot`, which owns the active state and
* change handling; the matching `TabsContent` panels are siblings in the root.
*/
function VariableTypeTabs(): JSX.Element {
return (
<div className={styles.typePicker}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeTabsScroll}>
<TabsList variant="secondary" className={styles.typeTabs}>
<TabsTrigger
value="DYNAMIC"
className={styles.typeTab}
testId="variable-type-dynamic"
>
<Pyramid size={14} />
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</TabsTrigger>
<TabsTrigger
value="TEXT"
className={styles.typeTab}
testId="variable-type-textbox"
>
<ClipboardType size={14} />
Textbox
</TabsTrigger>
<TabsTrigger
value="CUSTOM"
className={styles.typeTab}
testId="variable-type-custom"
>
<LayoutList size={14} />
Custom
</TabsTrigger>
<TabsTrigger
value="QUERY"
className={styles.typeTab}
testId="variable-type-query"
>
<DatabaseZap size={14} />
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
<span
className={styles.betaTag}
onClick={(e): void => e.stopPropagation()}
role="presentation"
>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</span>
</TabsTrigger>
</TabsList>
</div>
</div>
);
}
export default VariableTypeTabs;

View File

@@ -1,191 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { detectVariableCycle } from '../variableDependencies';
import {
sortValuesByOrder,
type VariableFormModel,
type VariableType,
} from '../variableFormModel';
import { getAttributeError, getNameError } from './variableValidation';
// Stable reference so the zustand selector never returns a fresh object (which
// would make useSyncExternalStore loop) when this dashboard has no selections.
const EMPTY_SELECTIONS: VariableSelectionMap = {};
interface UseVariableFormArgs {
initial: VariableFormModel;
siblings: VariableFormModel[];
isNew: boolean;
onSave: (model: VariableFormModel) => void;
}
export interface UseVariableForm {
model: VariableFormModel;
set: (patch: Partial<VariableFormModel>) => void;
onNameChange: (value: string) => void;
selectType: (type: VariableType) => void;
onCustomChange: (value: string) => void;
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
setRawPreview: (values: (string | number)[]) => void;
previewValues: (string | number)[];
previewError: string | null;
setPreviewError: (message: string | null) => void;
defaultValue: string;
setDefaultValue: (value: string) => void;
visibleNameError: string | null;
nameError: string | null;
attributeError: string | undefined;
cycleError: string | null;
isListType: boolean;
showAllOptionField: boolean;
payloadVariables: PayloadVariables;
handleSave: () => void;
}
const readDefaultValue = (model: VariableFormModel): string =>
((model.defaultValue as { value?: string })?.value ?? '') as string;
/** Form state, derivations and handlers for the variable editor. */
export function useVariableForm({
initial,
siblings,
isNew,
onSave,
}: UseVariableFormArgs): UseVariableForm {
const [model, setModel] = useState<VariableFormModel>(initial);
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
// preview re-sorts when Sort changes.
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [cycleError, setCycleError] = useState<string | null>(null);
// In add mode, mirror the chosen attribute into the name until the user types.
const [nameTouched, setNameTouched] = useState(false);
const [defaultValue, setDefaultValue] = useState<string>(
readDefaultValue(initial),
);
useEffect(() => {
setModel(initial);
setRawPreview([]);
setPreviewError(null);
setCycleError(null);
setNameTouched(false);
setDefaultValue(readDefaultValue(initial));
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const previewValues = useMemo(
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
[rawPreview, model.sort],
);
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
const existingDynamicAttributes = useMemo(
() =>
siblings
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
.map((v) => v.dynamicAttribute),
[siblings],
);
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
);
const payloadVariables = useMemo<PayloadVariables>(() => {
const out: PayloadVariables = {};
siblings.forEach((v) => {
if (v.name) {
out[v.name] = selections[v.name]?.value ?? null;
}
});
return out;
}, [siblings, selections]);
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames, initial.name);
// Surface the message only once the field is dirty; Save stays disabled regardless.
const visibleNameError = nameTouched ? nameError : null;
const attributeError = getAttributeError(model, existingDynamicAttributes);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const onNameChange = (value: string): void => {
setNameTouched(true);
set({ name: value });
};
const selectType = (type: VariableType): void => {
set({ type });
setRawPreview([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setRawPreview(commaValuesParser(value));
};
// In add mode, mirror the selected attribute into the name until the user
// edits the name themselves (matches the V1 dynamic-variable behaviour).
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
if (isNew && !nameTouched && patch.dynamicAttribute) {
set({ ...patch, name: patch.dynamicAttribute });
} else {
set(patch);
}
};
const handleSave = (): void => {
const next: VariableFormModel = {
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
};
const cycle = detectVariableCycle([...siblings, next]);
if (cycle) {
setCycleError(
`Cannot save: circular dependency detected between variables: ${cycle.join(
' → ',
)}`,
);
return;
}
setCycleError(null);
onSave(next);
};
return {
model,
set,
onNameChange,
selectType,
onCustomChange,
onDynamicChange,
setRawPreview,
previewValues,
previewError,
setPreviewError,
defaultValue,
setDefaultValue,
visibleNameError,
nameError,
attributeError,
cycleError,
isListType,
showAllOptionField,
payloadVariables,
handleSave,
};
}

View File

@@ -1,37 +0,0 @@
import type { VariableFormModel } from '../variableFormModel';
/**
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
* set includes self, but keeping your own (original) name is always allowed.
*/
export function getNameError(
name: string,
existingNames: string[],
originalName: string,
): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (name !== originalName && existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
export function getAttributeError(
model: VariableFormModel,
existingDynamicAttributes: string[],
): string | undefined {
if (
model.type === 'DYNAMIC' &&
model.dynamicAttribute &&
existingDynamicAttributes.includes(model.dynamicAttribute)
) {
return 'A variable with this attribute key already exists';
}
return undefined;
}

View File

@@ -1,140 +0,0 @@
import type { CSSProperties } from 'react';
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableFormModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariableRowProps {
variable: VariableFormModel;
index: number;
canEdit: boolean;
/** True when this row's delete is awaiting inline confirmation. */
isConfirmingDelete: boolean;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
}
/** A single draggable variable row (drag handle + meta + inline actions). */
function VariableRow({
variable,
index,
canEdit,
isConfirmingDelete,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
}: VariableRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: variable.name });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
};
return (
<div
ref={setNodeRef}
style={style}
className={styles.row}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
{canEdit ? (
<span
ref={setActivatorNodeRef}
className={styles.dragHandle}
aria-label="Reorder variable"
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</span>
) : null}
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && isConfirmingDelete ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && !isConfirmingDelete ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
);
}
export default VariableRow;

View File

@@ -2,11 +2,13 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
justify-content: flex-end;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
@@ -28,6 +30,14 @@
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
@@ -52,15 +62,6 @@
min-width: 0;
}
.dragHandle {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--l3-foreground);
cursor: grab;
touch-action: none;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);

View File

@@ -1,20 +1,24 @@
import type { DragEndEvent } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import VariableRow from './VariableRow';
import type { VariableFormModel } from './variableFormModel';
import type { VariableFormModel } from './variableModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
@@ -37,48 +41,98 @@ function VariablesList({
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
);
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
if (!over || active.id === over.id) {
return;
}
const from = variables.findIndex((v) => v.name === active.id);
const to = variables.findIndex((v) => v.name === over.id);
if (from !== -1 && to !== -1) {
onMove(from, to);
}
};
return (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={variables.map((v) => v.name)}
strategy={verticalListSortingStrategy}
>
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<VariableRow
key={variable.name || `variable-${index}`}
variable={variable}
index={index}
canEdit={canEdit}
isConfirmingDelete={confirmingIndex === index}
onEdit={onEdit}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
/>
))}
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
</SortableContext>
</DndContext>
))}
</div>
);
}

View File

@@ -1,26 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
const AddVariableButton = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: (state: { type: 'new' }) => void;
}): JSX.Element => {
return (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
size="md"
onClick={(): void => setIsEditing({ type: 'new' })}
testId="add-variable"
disabled={!isEditable}
>
Add variable
</Button>
);
};
export default AddVariableButton;

View File

@@ -1,11 +0,0 @@
.backToAllVariables {
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--l3-border);
}
.backToAllVariablesButton {
--button-font-size: 14px;
--button-padding: var(--spacing-5) var(--spacing-3);
color: var(--l1-foreground);
}

View File

@@ -1,28 +0,0 @@
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './BackToAllVariables.module.scss';
import { VariableFormProps } from '../../types';
const BackToAllVariables = ({
onClose,
}: {
onClose: VariableFormProps['onClose'];
}): JSX.Element => {
return (
<div className={styles.backToAllVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.backToAllVariablesButton}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
size="md"
>
All variables
</Button>
</div>
);
};
export default BackToAllVariables;

View File

@@ -1,25 +0,0 @@
.noVariablesCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.noVariablesCopy {
display: flex;
flex-direction: column;
gap: 2px;
}
.noVariablesTitle {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.noVariablesInfo {
color: var(--l3-foreground);
font-size: 13px;
line-height: 18px;
}

View File

@@ -1,28 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import AddVariableButton from '../AddVariableButton';
import { EditingState } from '../../types';
import styles from './NoVariables.module.scss';
const NoVariablesCard = ({
isEditable,
setIsEditing,
}: {
isEditable: boolean;
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
}): JSX.Element => {
return (
<div className={styles.noVariablesCard}>
<div className={styles.noVariablesCopy}>
<Typography.Text className={styles.noVariablesTitle}>
No variables yet
</Typography.Text>
<Typography.Text className={styles.noVariablesInfo}>
Create a variable to parameterize your panel queries.
</Typography.Text>
</div>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
);
};
export default NoVariablesCard;

View File

@@ -1,25 +0,0 @@
.infoItemContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.infoTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
}
.variableNameInput {
border-radius: 2px;
border: 1px solid var(--l2-border);
&::placeholder {
color: var(--l3-foreground);
}
}
.descriptionTextArea {
border-radius: 2px;
border: 1px solid var(--l2-border);
}

View File

@@ -1,60 +0,0 @@
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
import { Input as AntdInput } from 'antd';
import styles from './VariableInfoForm.module.scss';
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
interface VariableInfoFormProps {
title: string;
description: string;
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
visibleNameError: string | null;
}
function VariableInfoForm({
title,
description,
onTitleChange,
onDescriptionChange,
visibleNameError,
}: VariableInfoFormProps): JSX.Element {
return (
<>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Name</Typography>
<Input
testId="variable-name"
className={styles.variableNameInput}
value={title}
onChange={(e): void => onTitleChange(e.target.value)}
placeholder="Unique name of the variable"
/>
{visibleNameError ? (
<Typography.Text className={variableFormStyles.errorText}>
<sup>*</sup>&nbsp;
{visibleNameError}
</Typography.Text>
) : null}
</div>
<div className={styles.infoItemContainer}>
<Typography className={styles.infoTitle}>Description</Typography>
<AntdInput.TextArea
className={styles.descriptionTextArea}
value={description}
placeholder="Enter a description for the variable"
data-testid="dashboard-desc"
rows={3}
onChange={(e): void => onDescriptionChange(e.target.value)}
/>
</div>
</>
);
}
export default VariableInfoForm;

View File

@@ -1,75 +1,75 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import settingsStyles from '../DashboardSettings.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
} from './variableModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
import { EditingState } from './types';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialFormModels = useMemo(
() => dashboard.spec.variables.map(dtoToFormModel),
[dashboard.spec.variables],
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const [variables, setVariables] =
useState<VariableFormModel[]>(initialFormModels);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialFormModels);
setVariables(initialModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [isEditing, setIsEditing] = useState<EditingState>(null);
const [editing, setEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
return null;
}
return isEditing.type === 'new'
return editing.index === null
? emptyVariableFormModel()
: variables[isEditing.index];
}, [isEditing, variables]);
: variables[editing.index];
}, [editing, variables]);
const siblings = useMemo(() => {
const self = isEditing?.type === 'edit' ? isEditing.index : null;
return variables.filter((_, i) => i !== self);
}, [variables, isEditing]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (Formmodel: VariableFormModel): void => {
const handleFormSave = (model: VariableFormModel): void => {
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(Formmodel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = Formmodel;
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
}
setIsEditing(null);
setEditing(null);
persist(next);
};
@@ -88,14 +88,14 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
if (editingFormModel) {
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
return (
<VariableForm
initial={editingFormModel}
siblings={siblings}
isNew={isEditing?.type === 'new'}
initial={editingModel}
existingNames={existingNames}
isSaving={isSaving}
onClose={(): void => setIsEditing(null)}
onClose={(): void => setEditing(null)}
onSave={handleFormSave}
/>
);
@@ -103,25 +103,42 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
// Master view — the variables list.
return (
<div className={cx(styles.container, settingsStyles.settingsCard)}>
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
{variables.length === 0 ? (
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
) : (
<>
<div className={styles.header}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
</div>
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
</>
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
)}
</div>
);

View File

@@ -1,18 +0,0 @@
import { VariableFormModel } from './variableFormModel';
/** `null` index = adding a new variable; a number = editing that row. */
export type EditingState =
| { type: 'new' }
| { type: 'edit'; index: number }
| null;
export interface VariableFormProps {
initial: VariableFormModel;
/** The other variables (excluding this one), for uniqueness & cycle checks. */
siblings: VariableFormModel[];
/** True when adding a new variable (enables auto-naming from the attribute). */
isNew: boolean;
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}

View File

@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {

View File

@@ -4,6 +4,7 @@ import {
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
@@ -13,24 +14,21 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
VARIABLE_SORT_DISABLED,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableFormModel';
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec.display;
const display = dto.spec?.display;
const common: VariableFormModel = {
...base,
// TODO
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
};
@@ -52,7 +50,7 @@ export function dtoToFormModel(
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
@@ -69,9 +67,7 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// An omitted wire signal means "all telemetry".
dynamicSignal:
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -99,7 +95,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: signalForApi(model.dynamicSignal),
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
@@ -118,6 +114,7 @@ export function formModelToDto(
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
@@ -138,10 +135,7 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
@@ -155,3 +149,5 @@ export function variableTypeOf(
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -1,35 +0,0 @@
import {
buildDependencies,
buildDependencyGraph,
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
import type { VariableFormModel } from './variableFormModel';
/**
* Detects a circular reference among QUERY variables (a query referencing
* another that, transitively, references it back). Reuses the V1 dependency
* graph helpers, which key off `name` / `type` / `queryValue` only.
*
* Returns the names forming the cycle, or `null` when the set is acyclic.
*/
export function detectVariableCycle(
variables: VariableFormModel[],
): string[] | null {
const asDbVariables = variables
.filter((variable) => variable.name)
.map(
(variable) =>
({
name: variable.name,
type: variable.type,
queryValue: variable.queryValue,
}) as IDashboardVariable,
);
const { hasCycle, cycleNodes } = buildDependencyGraph(
buildDependencies(asDbVariables),
);
return hasCycle ? (cycleNodes ?? []) : null;
}

View File

@@ -1,154 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
import { sortBy } from 'lodash-es';
/**
* The four variable types the editor exposes. No generated enum exists for this
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
* ever exposes a single variable-kind type.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
/**
* Sort order for list-variable values. The wire (Perses) validates `sort`
* against a fixed method set. There is no generated TS enum for it
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
* so we mirror the Perses `Sort` tokens here.
*/
export const VARIABLE_SORT = {
DISABLED: 'none',
ASC: 'alphabetical-asc',
DESC: 'alphabetical-desc',
NUMERICAL_ASC: 'numerical-asc',
NUMERICAL_DESC: 'numerical-desc',
CI_ASC: 'alphabetical-ci-asc',
CI_DESC: 'alphabetical-ci-desc',
} as const;
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
/** Persisted "no sort" value (Perses `none`). */
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
export const VARIABLE_SORTS: VariableSort[] = [
VARIABLE_SORT.DISABLED,
VARIABLE_SORT.ASC,
VARIABLE_SORT.DESC,
VARIABLE_SORT.NUMERICAL_ASC,
VARIABLE_SORT.NUMERICAL_DESC,
VARIABLE_SORT.CI_ASC,
VARIABLE_SORT.CI_DESC,
];
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.DISABLED]: 'Disabled',
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
export function signalForApi(
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
type SortableValues = (string | number | boolean)[];
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
export function sortValuesByOrder(
values: SortableValues,
sort: VariableSort,
): SortableValues {
switch (sort) {
case VARIABLE_SORT.ASC:
return sortBy(values);
case VARIABLE_SORT.DESC:
return sortBy(values).reverse();
case VARIABLE_SORT.NUMERICAL_ASC:
return sortBy(values, (value) => Number(value));
case VARIABLE_SORT.NUMERICAL_DESC:
return sortBy(values, (value) => Number(value)).reverse();
case VARIABLE_SORT.CI_ASC:
return sortBy(values, (value) => String(value).toLowerCase());
case VARIABLE_SORT.CI_DESC:
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
default:
return values;
}
}
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
type: 'DYNAMIC',
multiSelect: false,
showAllOption: false,
sort: VARIABLE_SORT_DISABLED,
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: DYNAMIC_SIGNAL_ALL,
};
}

View File

@@ -0,0 +1,78 @@
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}

View File

@@ -1,114 +0,0 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import ValueSelector from './selectors/ValueSelector';
import styles from './VariablesBar.module.scss';
interface VariableSelectorProps {
variable: VariableFormModel;
/** All variables (Dynamic uses them to scope options by sibling selections). */
variables: VariableFormModel[];
/** Names this variable depends on (for Query gating). */
parents: string[];
/** All current selections (Query passes them as the request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/** One labelled variable control; dispatches on the variable type. */
function VariableSelector({
variable,
variables,
parents,
selections,
selection,
onChange,
}: VariableSelectorProps): JSX.Element {
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
parents={parents}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
/>
);
case 'CUSTOM':
default:
return (
<ValueSelector
options={customOptions}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
};
return (
<div
className={styles.variableItem}
data-testid={`variable-${variable.name}`}
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size="md" />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
</div>
);
}
export default VariableSelector;

View File

@@ -1,71 +0,0 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
}
.variableItem {
display: flex;
align-items: center;
}
.variableName {
display: flex;
min-width: 56px;
height: 32px;
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: 16px;
white-space: nowrap;
}
.infoIcon {
margin-left: 4px;
color: var(--l2-foreground);
}
.variableValue {
display: flex;
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l1-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;
:global(.ant-select-selector),
:global(.ant-input),
&:global(.ant-input) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
}

View File

@@ -1,45 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
import styles from './VariablesBar.module.scss';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
if (variables.length === 0) {
return null;
}
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}
export default VariablesBar;

View File

@@ -1,56 +0,0 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
function formatQueryValue(val: string): string {
const num = Number(val);
if (!Number.isNaN(num) && Number.isFinite(num)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter expression from the OTHER dynamic variables' current
* selections (e.g. `k8s.namespace.name IN ['prod'] AND service = 'api'`), so a
* dynamic variable's option list is scoped by its sibling selections. Variables
* in the ALL state, with no selection, or non-dynamic are skipped. Ported from
* the V1 dynamic-variable runtime.
*/
export function buildExistingDynamicVariableQuery(
variables: VariableFormModel[],
selections: VariableSelectionMap,
currentName: string,
): string {
const parts: string[] = [];
variables.forEach((variable) => {
if (
variable.name === currentName ||
variable.type !== 'DYNAMIC' ||
!variable.dynamicAttribute
) {
return;
}
const selection = selections[variable.name];
if (!selection || selection.allSelected) {
return;
}
const raw = Array.isArray(selection.value)
? selection.value
: [selection.value];
const valid = raw
.filter((v) => v !== null && v !== undefined && v !== '')
.map((v) => String(v));
if (valid.length > 0) {
parts.push(buildQueryPart(variable.dynamicAttribute, valid));
}
});
return parts.join(' AND ');
}

View File

@@ -1,16 +0,0 @@
/** A user-selected variable value at runtime (not persisted to the spec). */
export type SelectedVariableValue =
| string
| number
| boolean
| (string | number | boolean)[]
| null;
export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
}
/** Selected values for a dashboard's variables, keyed by variable name. */
export type VariableSelectionMap = Record<string, VariableSelection>;

View File

@@ -1,31 +0,0 @@
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
/** A selection counts as resolved (usable as a parent value) when it's non-empty. */
export function isResolved(selection?: VariableSelection): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
return true;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,
): Record<string, SelectedVariableValue> {
const payload: Record<string, SelectedVariableValue> = {};
Object.entries(selection).forEach(([name, sel]) => {
payload[name] = sel.value;
});
return payload;
}

View File

@@ -1,82 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`).
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const { data, isFetching } = useGetFieldValues({
signal: signalForApi(variable.dynamicSignal),
name: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: !!variable.dynamicAttribute,
});
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -1,90 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { isResolved, selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** Names this variable's query references; it waits until they're resolved. */
parents: string[];
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
}
/**
* Query-driven options. Dependency orchestration is declarative: the query is
* `enabled` only once every parent is resolved, and the parent values are in the
* query key — so it refetches automatically when a parent changes (and a cyclic
* dependency is simply never enabled).
*/
function QuerySelector({
variable,
parents,
selections,
selection,
onChange,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const enabled = parents.every((parent) => isResolved(selections[parent]));
const { data, isFetching } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
payload,
minTime,
maxTime,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{ enabled, refetchOnWindowFocus: false },
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onChange);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -1,70 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
/** Configured default; an emptied input falls back to it (V1 behaviour). */
defaultValue?: string;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Free-text variable input. Mirrors V1: edits are local and only committed on
* blur / Enter (not per keystroke), and clearing the field restores the default.
*/
function TextSelector({
selection,
defaultValue,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const [value, setValue] = useState<string>(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
const commit = useCallback(
(next: string): void => onChange({ value: next, allSelected: false }),
[onChange],
);
const handleBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const trimmed = event.target.value.trim();
if (!trimmed && defaultValue) {
setValue(defaultValue);
commit(defaultValue);
} else {
commit(trimmed);
}
},
[commit, defaultValue],
);
return (
<Input
ref={inputRef}
className={styles.control}
bordered={false}
placeholder="Enter value"
value={value}
title={value}
onChange={(e): void => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
inputRef.current?.blur();
}
}}
data-testid={testId}
/>
);
}
export default TextSelector;

View File

@@ -1,94 +0,0 @@
import { useMemo } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface ValueSelectorProps {
options: string[];
multiSelect: boolean;
showAllOption: boolean;
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
*/
function ValueSelector({
options,
multiSelect,
showAllOption,
loading,
selection,
onChange,
testId,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
);
if (multiSelect) {
const value = selection.allSelected
? ALL_SELECT_VALUE
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.control}
data-testid={testId}
options={optionData}
value={value}
loading={loading}
showSearch
placeholder="Select value"
enableAllSelection={showAllOption}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}
return (
<CustomSelect
className={styles.select}
data-testid={testId}
options={optionData}
value={
selection.value == null || Array.isArray(selection.value)
? undefined
: String(selection.value)
}
loading={loading}
showSearch
placeholder="Select value"
onChange={(next): void =>
onChange({ value: next == null ? '' : String(next), allSelected: false })
}
/>
);
}
export default ValueSelector;

View File

@@ -1,41 +0,0 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from './selectionTypes';
/**
* When fetched options arrive and the current selection isn't one of them,
* auto-pick the variable's default (if present in the options) or the first
* option — so dependent children always have a usable parent value.
*/
export function useAutoSelect(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
onChange: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0 || selection.allSelected) {
return;
}
const current = selection.value;
const isValid = Array.isArray(current)
? current.length > 0 && current.every((c) => options.includes(String(c)))
: current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current));
if (isValid) {
return;
}
const fallback = (variable.defaultValue as { value?: string } | undefined)
?.value;
const initial =
fallback && options.includes(fallback) ? fallback : options[0];
onChange({
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -1,116 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from './selectionTypes';
import {
computeVariableDependencies,
type VariableDependencyData,
} from './variableDependencies';
/** URL sentinel for an "ALL values selected" state (matches V1). */
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = (
model.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
if (def !== undefined && def !== null && def !== '') {
return { value: def, allSelected: false };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
function fromUrlValue(raw: SelectedVariableValue): VariableSelection {
return raw === ALL_SELECTED
? { value: null, allSelected: true }
: { value: raw, allSelected: false };
}
interface UseVariableSelection {
variables: VariableFormModel[];
dependencyData: VariableDependencyData;
selection: VariableSelectionMap;
setSelection: (name: string, selection: VariableSelection) => void;
}
/**
* Runtime variable selection: derives the variable list from the spec, seeds
* each value from URL → localStorage(store) → default, and persists changes to
* both the store and the URL. Never writes to the dashboard spec.
*/
export function useVariableSelection(
dashboard: DashboardtypesGettableDashboardV2DTO,
): UseVariableSelection {
const dashboardId = dashboard.id ?? '';
const variables = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const dependencyData = useMemo(
() => computeVariableDependencies(variables),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((s) => s.setVariableValue);
const setVariableValues = useDashboardStore((s) => s.setVariableValues);
const [urlValues, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Seed selections for this dashboard: URL wins, then persisted store, then default.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
}
// `selection` here is the persisted (localStorage) map on mount — the
// effect deliberately doesn't depend on it, so seeding runs once per set.
const stored = selection;
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue);
} else if (stored[variable.name]) {
seeded[variable.name] = stored[variable.name];
} else {
seeded[variable.name] = defaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, variables]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
return { variables, dependencyData, selection, setSelection };
}

View File

@@ -1,199 +0,0 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
* "depends on" another variable when its query text references that variable
* (`{{.name}}`, `{{name}}`, `$name`, `[[name]]`). When a variable's value
* changes, its dependent QUERY variables must refetch. Ported from the V1
* dashboard-variables runtime; operates on the V2 flat variable model.
*/
export type VariableGraph = Record<string, string[]>;
export interface VariableDependencyData {
/** Topological order of variables (parents before children). */
order: string[];
/** Direct children (dependents) of each variable. */
graph: VariableGraph;
/** Direct parents of each variable. */
parentGraph: VariableGraph;
/** All transitive descendants of each variable (precomputed). */
transitiveDescendants: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
/** Names of QUERY variables whose query references `variableName`. */
function getDependents(
variableName: string,
variables: VariableFormModel[],
): string[] {
return variables
.filter(
(v) =>
v.type === 'QUERY' &&
!!v.name &&
textContainsVariableReference(v.queryValue || '', variableName),
)
.map((v) => v.name);
}
/** variable name → its direct dependents (children). */
export function buildDependencies(
variables: VariableFormModel[],
): VariableGraph {
const graph: VariableGraph = {};
variables.forEach((v) => {
if (v.name) {
graph[v.name] = getDependents(v.name, variables);
}
});
return graph;
}
/** Invert a child graph into a parent graph. */
export function buildParentGraph(graph: VariableGraph): VariableGraph {
const parents: VariableGraph = {};
Object.keys(graph).forEach((node) => {
parents[node] = parents[node] ?? [];
});
Object.entries(graph).forEach(([node, children]) => {
children.forEach((child) => {
parents[child] = parents[child] ?? [];
parents[child].push(node);
});
});
return parents;
}
function collectCyclePath(
graph: VariableGraph,
start: string,
end: string,
): string[] {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
}
function detectCycle(
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
let cycleNodes: string[] | null = null;
(graph[node] || []).some((neighbor) => {
if (!visited.has(neighbor)) {
const found = detectCycle(graph, neighbor, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
} else if (recStack.has(neighbor)) {
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
}
/** Build the full dependency data (topo order, parents, transitive descendants, cycle info). */
export function buildDependencyData(
dependencies: VariableGraph,
): VariableDependencyData {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
Object.keys(dependencies).forEach((node) => {
inDegree[node] = inDegree[node] ?? 0;
adjList[node] = adjList[node] ?? [];
(dependencies[node] || []).forEach((child) => {
inDegree[child] = inDegree[child] ?? 0;
inDegree[child] += 1;
adjList[node].push(child);
});
});
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const found = detectCycle(dependencies, node, visited, recStack);
if (found) {
cycleNodes = found;
return true;
}
}
return false;
});
// Topological sort (Kahn's algorithm).
const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
const order: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
order.push(current);
(adjList[current] || []).forEach((neighbor) => {
inDegree[neighbor] -= 1;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = order.length !== Object.keys(dependencies).length;
// Transitive descendants: walk topo order in reverse.
const transitiveDescendants: VariableGraph = {};
for (let i = order.length - 1; i >= 0; i--) {
const node = order[i];
const desc = new Set<string>();
(adjList[node] || []).forEach((child) => {
desc.add(child);
(transitiveDescendants[child] || []).forEach((d) => desc.add(d));
});
transitiveDescendants[node] = Array.from(desc);
}
return {
order,
graph: adjList,
parentGraph: buildParentGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
}
/** Compute the full dependency data straight from the variable list. */
export function computeVariableDependencies(
variables: VariableFormModel[],
): VariableDependencyData {
return buildDependencyData(buildDependencies(variables));
}

View File

@@ -1,55 +0,0 @@
import type { StateCreator } from 'zustand';
import type {
VariableSelection,
VariableSelectionMap,
} from '../../VariablesBar/selectionTypes';
import type { DashboardStore } from '../useDashboardStore';
/**
* Runtime variable selection — the values the user picks in the variable bar.
* Keyed by dashboardId → variable name. Frontend-only and persisted to
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
setVariableValue: (
dashboardId: string,
name: string,
selection: VariableSelection,
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
}
export const createVariableSelectionSlice: StateCreator<
DashboardStore,
[['zustand/persist', unknown]],
[],
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
variableValues: {
...variableValues,
[dashboardId]: { ...variableValues[dashboardId], [name]: selection },
},
});
},
setVariableValues: (dashboardId, values): void => {
const { variableValues } = get();
set({
variableValues: { ...variableValues, [dashboardId]: values },
});
},
});
/** Selector: the selection map for a dashboard (empty if none). */
export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? {};

View File

@@ -9,36 +9,25 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
export type DashboardStore = EditContextSlice & CollapseSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* - edit-context: dashboardId / isEditable / refetch (set once, not persisted).
* - collapse: per-section open state (frontend-only, persisted to localStorage).
* - variable-selection: runtime variable values (frontend-only, persisted).
*/
export const useDashboardStore = create<DashboardStore>()(
persist(
(...a) => ({
...createEditContextSlice(...a),
...createCollapseSlice(...a),
...createVariableSelectionSlice(...a),
}),
{
name: '@signoz/dashboard-v2',
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
// Persist only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
},
),
);

View File

@@ -51,6 +51,26 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/preview", handler.New(
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.PreviewMapping),
handler.OpenAPIDef{
ID: "PreviewSpanMapping",
Tags: []string{"spanmapper"},
Summary: "Preview span attribute mapping against a sample span",
Description: "Previews how attribute mappings would transform a sample span.",
Request: new(spantypes.SpanMappingPreviewRequest),
RequestContentType: "application/json",
Response: new(spantypes.SpanMappingPreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateGroup),
handler.OpenAPIDef{

View File

@@ -304,7 +304,7 @@ func TestCompositeKeyFromLabels(t *testing.T) {
name: "daemonset and namespace group-by",
labels: map[string]string{
"k8s.daemonset.name": "web-1",
"k8s.namespace.name": "ns-x",
"k8s.namespace.name": "ns-x",
},
groupBy: []qbtypes.GroupByKey{daemonSetNameGroupByKey, namespaceNameGroupByKey},
expected: "web-1\x00ns-x",
@@ -330,47 +330,6 @@ func TestCompositeKeyFromLabels(t *testing.T) {
groupBy: []qbtypes.GroupByKey{deploymentNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
expected: "web-1\x00ns-x\x00",
},
{
// volumes default group identity: (pvc, namespace, cluster).
name: "pvc, namespace and cluster group-by",
labels: map[string]string{
"k8s.persistentvolumeclaim.name": "data-pg-0",
"k8s.namespace.name": "ns-x",
"k8s.cluster.name": "cluster-a",
},
groupBy: []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
expected: "data-pg-0\x00ns-x\x00cluster-a",
},
{
// absent cluster label on a PVC -> empty trailing segment.
name: "pvc missing cluster label yields empty trailing segment",
labels: map[string]string{
"k8s.persistentvolumeclaim.name": "data-pg-0",
"k8s.namespace.name": "ns-x",
},
groupBy: []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
expected: "data-pg-0\x00ns-x\x00",
},
{
// namespaces default group identity: (namespace, cluster) — namespaces are
// cluster-scoped, so cluster is the only cross-cluster disambiguator.
name: "namespace and cluster group-by",
labels: map[string]string{
"k8s.namespace.name": "ns-x",
"k8s.cluster.name": "cluster-a",
},
groupBy: []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey},
expected: "ns-x\x00cluster-a",
},
{
// absent cluster label on a namespace -> empty trailing segment.
name: "namespace missing cluster label yields empty trailing segment",
labels: map[string]string{
"k8s.namespace.name": "ns-x",
},
groupBy: []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey},
expected: "ns-x\x00",
},
}
for _, tt := range tests {

View File

@@ -360,7 +360,7 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey}
req.GroupBy = []qbtypes.GroupByKey{namespaceNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
@@ -535,7 +535,7 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey}
req.GroupBy = []qbtypes.GroupByKey{pvcNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList

View File

@@ -273,6 +273,35 @@ func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// PreviewMapping handles POST /api/v1/span_mapper_groups/preview.
// used to get preview of attributes/resources after remapping.
func (h *handler) PreviewMapping(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
req := new(spantypes.SpanMappingPreviewRequest)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.PreviewMapping(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
vars := mux.Vars(r)

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
@@ -102,6 +103,97 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
return nil
}
// PreviewMapping resolves the mappings to preview (from the request body, a
// saved group, or all enabled saved mappings) and returns the input span with
// its "attributes" and "resource" maps transformed.
func (module *module) PreviewMapping(ctx context.Context, orgID valuer.UUID, req *spantypes.SpanMappingPreviewRequest) (*spantypes.SpanMappingPreviewResponse, error) {
groups, err := module.resolvePreviewGroups(ctx, orgID, req)
if err != nil {
return nil, err
}
if len(req.Span) == 0 {
return nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'span' must be provided")
}
outResource, outSpan := spantypes.SimulateMappingForAttributes(groups, spanAttrMap(req.Span["resource"]), spanAttrMap(req.Span["attributes"]))
result := make(map[string]any, len(req.Span))
for k, v := range req.Span {
result[k] = v
}
setAttrMap(result, "attributes", req.Span, outSpan)
setAttrMap(result, "resource", req.Span, outResource)
return &spantypes.SpanMappingPreviewResponse{Span: result}, nil
}
func spanAttrMap(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
return nil
}
func setAttrMap(dst map[string]any, key string, in map[string]any, transformed map[string]any) {
if _, present := in[key]; present || len(transformed) > 0 {
dst[key] = transformed
}
}
// resolvePreviewGroups picks the mappings to preview against: the groups in the
// request body, else a specific saved group (GroupID), else all of the org's
// enabled saved mappings.
func (module *module) resolvePreviewGroups(ctx context.Context, orgID valuer.UUID, req *spantypes.SpanMappingPreviewRequest) ([]*spantypes.SpanMapperGroupWithMappers, error) {
hasGroups := len(req.Groups) > 0
hasGroupID := req.GroupID != nil && *req.GroupID != ""
if hasGroups && hasGroupID {
return nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "provide either 'groups' or 'groupId', not both")
}
if hasGroups {
groups := make([]*spantypes.SpanMapperGroupWithMappers, 0, len(req.Groups))
for _, spec := range req.Groups {
group := &spantypes.SpanMapperGroup{
OrgID: orgID,
Name: spec.Group.Name,
Condition: spec.Group.Condition,
Enabled: spec.Group.Enabled,
}
mappers := make([]*spantypes.SpanMapper, 0, len(spec.Mappers))
for _, pm := range spec.Mappers {
mappers = append(mappers, &spantypes.SpanMapper{
Name: pm.Name,
FieldContext: pm.FieldContext,
Config: pm.Config,
Enabled: pm.Enabled,
})
}
groups = append(groups, &spantypes.SpanMapperGroupWithMappers{Group: group, Mappers: mappers})
}
return groups, nil
}
if hasGroupID {
id, err := valuer.NewUUID(*req.GroupID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "group id is not a valid uuid")
}
group, err := module.store.GetGroup(ctx, orgID, id)
if err != nil {
return nil, err
}
mappers, err := module.store.ListMappers(ctx, orgID, id)
if err != nil {
return nil, err
}
return []*spantypes.SpanMapperGroupWithMappers{{Group: group, Mappers: mappers}}, nil
}
return module.listEnabledGroupsWithMappers(ctx, orgID)
}
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
return spantypes.SpanAttrMappingFeatureType
}

View File

@@ -27,6 +27,7 @@ type Module interface {
CreateMapper(ctx context.Context, orgID, groupID valuer.UUID, mapper *spantypes.SpanMapper) error
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
PreviewMapping(ctx context.Context, orgID valuer.UUID, req *spantypes.SpanMappingPreviewRequest) (*spantypes.SpanMappingPreviewResponse, error)
}
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
@@ -42,4 +43,5 @@ type Handler interface {
CreateMapper(rw http.ResponseWriter, r *http.Request)
UpdateMapper(rw http.ResponseWriter, r *http.Request)
DeleteMapper(rw http.ResponseWriter, r *http.Request)
PreviewMapping(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -0,0 +1,117 @@
package spantypes
import (
"strings"
)
// resourceSourcePrefix marks a source that reads from resource attributes:
// buildAttributeRule prefixes those keys with "resource." (e.g. "resource.service.name").
var resourceSourcePrefix = FieldContextResource.StringValue() + "."
type SpanMappingPreviewGroup struct {
Group PostableSpanMapperGroup `json:"group" required:"true"`
Mappers []PostableSpanMapper `json:"mappers" required:"true" nullable:"true"`
}
type SpanMappingPreviewRequest struct {
Span map[string]any `json:"span" nullable:"true"`
Groups []SpanMappingPreviewGroup `json:"groups" nullable:"true"`
GroupID *string `json:"groupId" nullable:"true"`
}
type SpanMappingPreviewResponse struct {
Span map[string]any `json:"span" nullable:"true"`
}
func SimulateMappingForAttributes(groups []*SpanMapperGroupWithMappers, resourceAttrs, spanAttrs map[string]any) (outResource, outSpan map[string]any) {
cfg := buildProcessorConfig(filterEnabledGroupsWithMappers(groups))
outResource = cloneAttrs(resourceAttrs)
outSpan = cloneAttrs(spanAttrs)
applyEnabledGroups(cfg, outSpan, outResource)
return outResource, outSpan
}
func filterEnabledGroupsWithMappers(groups []*SpanMapperGroupWithMappers) []*SpanMapperGroupWithMappers {
out := make([]*SpanMapperGroupWithMappers, 0, len(groups))
for _, gm := range groups {
if gm == nil || gm.Group == nil || !gm.Group.Enabled {
continue
}
enabled := make([]*SpanMapper, 0, len(gm.Mappers))
for _, m := range gm.Mappers {
if m != nil && m.Enabled {
enabled = append(enabled, m)
}
}
if len(enabled) > 0 {
out = append(out, &SpanMapperGroupWithMappers{Group: gm.Group, Mappers: enabled})
}
}
return out
}
func cloneAttrs(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// The functions below are copied from signoz-otel-collector (processor/signozspanmappingprocessor, PR #796):
// TODO(spanmapper-preview): delete them and call the real processor once that PR merges and the dependency is bumped.
func applyEnabledGroups(cfg *spanMapperProcessorConfig, spanAttrs, resourceAttrs map[string]any) {
for i := range cfg.Groups {
g := &cfg.Groups[i]
if !spanMapperConditionMet(g.ExistsAny, spanAttrs, resourceAttrs) {
continue
}
for j := range g.Attributes {
applySpanMapperRule(&g.Attributes[j], spanAttrs, resourceAttrs)
}
}
}
func spanMapperConditionMet(cond spanMapperProcessorExistsAny, spanAttrs, resourceAttrs map[string]any) bool {
return anyKeyContains(spanAttrs, cond.Attributes) || anyKeyContains(resourceAttrs, cond.Resource)
}
func anyKeyContains(attrs map[string]any, patterns []string) bool {
for k := range attrs {
for _, p := range patterns {
if strings.Contains(k, p) {
return true
}
}
}
return false
}
func applySpanMapperRule(rule *spanMapperProcessorAttribute, spanAttrs, resourceAttrs map[string]any) {
dst := spanAttrs
if rule.Context == FieldContextResource.StringValue() {
dst = resourceAttrs
}
for i := range rule.Sources {
src := &rule.Sources[i]
sourceKey, isResource := strings.CutPrefix(src.Key, resourceSourcePrefix)
from := spanAttrs
if isResource {
from = resourceAttrs
}
val, ok := from[sourceKey]
if !ok {
continue
}
dst[rule.Target] = val
if src.Action == SpanMapperOperationMove.StringValue() {
delete(from, sourceKey)
}
return
}
}

View File

@@ -0,0 +1,159 @@
package spantypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func simGroup(name string, attrCond, resCond []string, mappers ...*SpanMapper) *SpanMapperGroupWithMappers {
return &SpanMapperGroupWithMappers{
Group: &SpanMapperGroup{
Name: name,
Condition: SpanMapperGroupCondition{Attributes: attrCond, Resource: resCond},
Enabled: true,
},
Mappers: mappers,
}
}
func simMapper(target string, ctx FieldContext, sources ...SpanMapperSource) *SpanMapper {
return &SpanMapper{
Name: target,
FieldContext: ctx,
Config: SpanMapperConfig{Sources: sources},
Enabled: true,
}
}
func simAttrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
}
func simResSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
}
func TestSimulate_MatchInSpanAttrs(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"model"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateMappingForAttributes(groups, nil, map[string]any{"llm.model": "gpt-4", "gen_ai.llm.model": "gpt-40"})
assert.Equal(t, "gpt-4", outSpan["gen_ai.request.model"])
}
func TestSimulate_MatchInResourceAttrs(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", nil, []string{"service.name"},
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simResSrc("service.name", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateMappingForAttributes(groups, map[string]any{"service.name": "my-llm-service"}, nil)
assert.Equal(t, "my-llm-service", outSpan["gen_ai.request.model"])
}
func TestSimulate_NoMatchSkipsGroup(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"model"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateMappingForAttributes(groups, nil, map[string]any{"some.other.key": "value"})
_, ok := outSpan["gen_ai.request.model"]
assert.False(t, ok, "target must not be set when condition is not met")
}
func TestSimulate_SourceFirstMatchWins(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("tokens", []string{"llm"}, nil,
simMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
simAttrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
simAttrSrc("llm.tokens", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateMappingForAttributes(groups, nil, map[string]any{"gen_ai.request_tokens": "100", "llm.tokens": "200"})
assert.Equal(t, "100", outSpan["gen_ai.request.tokens"])
}
func TestSimulate_SourceFallsBackToSecond(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("tokens", []string{"llm"}, nil,
simMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
simAttrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
simAttrSrc("llm.tokens", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateMappingForAttributes(groups, nil, map[string]any{"llm.tokens": "200"})
assert.Equal(t, "200", outSpan["gen_ai.request.tokens"])
}
func TestSimulate_ActionMove(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("input", []string{"gen_ai"}, nil,
simMapper("gen_ai.request.input", FieldContextSpanAttribute,
simAttrSrc("gen_ai.input", SpanMapperOperationMove, 1)),
),
}
_, outSpan := SimulateMappingForAttributes(groups, nil, map[string]any{"gen_ai.input": "hello"})
assert.Equal(t, "hello", outSpan["gen_ai.request.input"])
_, srcPresent := outSpan["gen_ai.input"]
assert.False(t, srcPresent, "source key must be deleted when action=move")
}
func TestSimulate_WriteToResourceContext(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"llm"}, nil,
simMapper("gen_ai.request.model", FieldContextResource,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)),
),
}
outResource, outSpan := SimulateMappingForAttributes(groups, nil, map[string]any{"llm.model": "gpt-4"})
assert.Equal(t, "gpt-4", outResource["gen_ai.request.model"], "target must be written to resource attributes")
_, inSpan := outSpan["gen_ai.request.model"]
assert.False(t, inSpan)
}
func TestSimulate_DisabledGroupsAndMappersSkipped(t *testing.T) {
disabledGroup := simGroup("g1", []string{"llm"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)))
disabledGroup.Group.Enabled = false
_, outSpan := SimulateMappingForAttributes([]*SpanMapperGroupWithMappers{disabledGroup}, nil, map[string]any{"llm.model": "gpt-4"})
_, ok := outSpan["gen_ai.request.model"]
assert.False(t, ok, "disabled groups must not be evaluated")
}
func TestSimulate_NoMappingsReturnsInputUnchanged(t *testing.T) {
outResource, outSpan := SimulateMappingForAttributes(nil, map[string]any{"host.name": "h1"}, map[string]any{"model": "gpt-5"})
assert.Equal(t, map[string]any{"host.name": "h1"}, outResource, "resource attributes returned unchanged")
assert.Equal(t, map[string]any{"model": "gpt-5"}, outSpan, "span attributes returned unchanged")
}
func TestSimulate_DoesNotMutateInput(t *testing.T) {
input := map[string]any{"gen_ai.input": "hi"}
groups := []*SpanMapperGroupWithMappers{
simGroup("input", []string{"gen_ai"}, nil,
simMapper("gen_ai.request.input", FieldContextSpanAttribute,
simAttrSrc("gen_ai.input", SpanMapperOperationMove, 1))),
}
_, _ = SimulateMappingForAttributes(groups, nil, input)
// Original input map must be untouched (move would have deleted the key).
_, ok := input["gen_ai.input"]
assert.True(t, ok, "input map must not be mutated by the preview")
}

View File

@@ -1,27 +0,0 @@
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -1,60 +0,0 @@
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -317,94 +317,23 @@ def test_namespaces_pod_phase_aggregation(
}
# Float record fields compared with tolerance; everything else compared with ==.
_GROUPBY_FLOAT_FIELDS = {
"namespaceCPU",
"namespaceMemory",
}
def _phase(pending=0, running=0, succeeded=0, failed=0, unknown=0) -> dict:
return {"pending": pending, "running": running, "succeeded": succeeded, "failed": failed, "unknown": unknown}
@pytest.mark.parametrize(
"scenario",
"group_key,expected_running",
[
# Explicit groupBy=[k8s.namespace.name]: one record per namespace,
# namespaceName populated (namespaces.go:27-30), response grouped_list.
# Each namespace has 1 running pod.
# groupBy=[k8s.namespace.name]: one record per namespace, namespaceName
# populated (namespaces.go:27-30). Each namespace has 1 running pod.
pytest.param(
{
"fixture": "namespaces_groupby.jsonl",
"group_by": "k8s.namespace.name",
"filter": None,
"group_meta_keys": ["k8s.namespace.name"],
"expected_type": "grouped_list",
"groups": {
"gb-ns-1": {"namespaceName": "gb-ns-1", "podCountsByPhase": _phase(running=1)},
"gb-ns-2": {"namespaceName": "gb-ns-2", "podCountsByPhase": _phase(running=1)},
"gb-ns-3": {"namespaceName": "gb-ns-3", "podCountsByPhase": _phase(running=1)},
"gb-ns-4": {"namespaceName": "gb-ns-4", "podCountsByPhase": _phase(running=1)},
},
},
"k8s.namespace.name",
{"gb-ns-1": 1, "gb-ns-2": 1, "gb-ns-3": 1, "gb-ns-4": 1},
id="namespace_name",
),
# Explicit groupBy=[k8s.cluster.name]: aggregated across each cluster's 2
# namespaces, namespaceName empty, response grouped_list. 2 running each.
# groupBy=[k8s.cluster.name]: aggregated across each cluster's 2
# namespaces, namespaceName empty. Each cluster has 2 x 1 = 2 running pods.
pytest.param(
{
"fixture": "namespaces_groupby.jsonl",
"group_by": "k8s.cluster.name",
"filter": None,
"group_meta_keys": ["k8s.cluster.name"],
"expected_type": "grouped_list",
"groups": {
"gb-cluster-a": {"namespaceName": "", "podCountsByPhase": _phase(running=2)},
"gb-cluster-b": {"namespaceName": "", "podCountsByPhase": _phase(running=2)},
},
},
"k8s.cluster.name",
{"gb-cluster-a": 2, "gb-cluster-b": 2},
id="cluster",
),
# Default groupBy (no groupBy in request) => [k8s.namespace.name,
# k8s.cluster.name] (module.go ListNamespaces), response list. Namespaces
# are cluster-scoped, so a same-named namespace must NOT collapse across
# clusters; the empty-cluster group (k8s.cluster.name label absent on the
# source pods) must appear as its own row with real metrics, not be dropped.
# Single pod per group => SpaceAggregationSum == seeded value.
# Fails on the pre-cluster default (name only) — the three groups would
# collapse into one summed row.
pytest.param(
{
"fixture": "namespaces_same_name_across_clusters.jsonl",
"group_by": None,
"filter": "k8s.namespace.name = 'dup-ns'",
"group_meta_keys": ["k8s.namespace.name", "k8s.cluster.name"],
"expected_type": "list",
"groups": {
("dup-ns", "cluster-a"): {
"namespaceName": "dup-ns",
"namespaceCPU": 0.3,
"namespaceMemory": 100000000.0,
"podCountsByPhase": _phase(running=1),
},
("dup-ns", "cluster-b"): {
"namespaceName": "dup-ns",
"namespaceCPU": 0.5,
"namespaceMemory": 300000000.0,
"podCountsByPhase": _phase(failed=1),
},
# empty-cluster group: k8s.cluster.name label absent on the source pods.
("dup-ns", ""): {
"namespaceName": "dup-ns",
"namespaceCPU": 0.1,
"namespaceMemory": 200000000.0,
"podCountsByPhase": _phase(pending=1),
},
},
},
id="default_disambiguates_cluster",
),
],
)
def test_namespaces_groupby(
@@ -412,64 +341,55 @@ def test_namespaces_groupby(
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
scenario: dict,
group_key: str,
expected_running: dict,
) -> None:
"""groupBy determines row identity. Explicit groupBy returns one grouped_list
record per distinct group (namespaceName populated only when grouping by
k8s.namespace.name; namespaces.go:27-30). With no groupBy the default is
[k8s.namespace.name, k8s.cluster.name] (module.go ListNamespaces), so
same-named namespaces across clusters stay as separate, un-collapsed list rows
(incl. an absent-cluster group keyed by ""). meta always surfaces the grouping
key(s)."""
"""groupBy returns one record per distinct group with aggregated pod-phase
counts. namespaceName is populated only when grouping by k8s.namespace.name
(namespaces.go:27-30 list-vs-grouped branch); meta surfaces the groupBy key."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path(f"inframonitoring/{scenario['fixture']}"),
get_testdata_file_path("inframonitoring/namespaces_groupby.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
body: dict = {
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
}
if scenario["group_by"] is not None:
body["groupBy"] = [{"name": scenario["group_by"], "fieldDataType": "string", "fieldContext": "resource"}]
if scenario["filter"] is not None:
body["filter"] = {"expression": scenario["filter"]}
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json=body,
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [
{
"name": group_key,
"fieldDataType": "string",
"fieldContext": "resource",
}
],
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["total"] == len(expected_running)
groups = scenario["groups"]
meta_keys = scenario["group_meta_keys"]
assert data["type"] == scenario["expected_type"]
assert data["total"] == len(groups)
group_of = lambda r: r["namespaceName"] if group_key == "k8s.namespace.name" else r["meta"][group_key] # noqa: E731 # pylint: disable=unnecessary-lambda-assignment
by_group = {group_of(r): r for r in data["records"]}
assert set(by_group.keys()) == set(expected_running.keys())
def _gid(rec: dict):
vals = [rec["meta"][k] for k in meta_keys]
return vals[0] if len(vals) == 1 else tuple(vals)
by_group = {_gid(r): r for r in data["records"]}
assert set(by_group.keys()) == set(groups.keys())
for gid, exp in groups.items():
rec = by_group[gid]
for k in meta_keys:
assert k in rec["meta"], rec["meta"]
for field, val in exp.items():
if field in _GROUPBY_FLOAT_FIELDS:
assert compare_values(rec[field], val, 1e-6), f"{gid}.{field}: got {rec[field]}, expected {val}"
else:
assert rec[field] == val, f"{gid}.{field}: got {rec[field]}, expected {val}"
for group, running in expected_running.items():
rec = by_group[group]
# namespaceName populated per namespace when grouping by k8s.namespace.name,
# empty otherwise.
assert rec["namespaceName"] == (group if group_key == "k8s.namespace.name" else "")
assert rec["podCountsByPhase"]["running"] == running
for other in ("pending", "succeeded", "failed", "unknown"):
assert rec["podCountsByPhase"][other] == 0
assert group_key in rec["meta"], rec["meta"]
def test_namespaces_pagination(

View File

@@ -376,111 +376,23 @@ def test_volumes_non_pvc_volume_filtered(
assert rec["persistentVolumeClaimName"] == "np-real-pvc"
# Float record fields compared with tolerance; everything else compared with ==.
_GROUPBY_FLOAT_FIELDS = {
"volumeAvailable",
"volumeCapacity",
"volumeUsage",
"volumeInodes",
"volumeInodesFree",
"volumeInodesUsed",
}
@pytest.mark.parametrize(
"scenario",
"group_key,expected_groups",
[
# Explicit groupBy=[k8s.persistentvolumeclaim.name]: one record per PVC,
# persistentVolumeClaimName populated (volumes.go:26-29), response grouped_list.
# groupBy=[k8s.persistentvolumeclaim.name]: one record per PVC,
# persistentVolumeClaimName populated (volumes.go:26-29).
pytest.param(
{
"fixture": "volumes_groupby.jsonl",
"group_by": "k8s.persistentvolumeclaim.name",
"filter": None,
"group_meta_keys": ["k8s.persistentvolumeclaim.name"],
"expected_type": "grouped_list",
"groups": {
"gb-pvc-a1": {"persistentVolumeClaimName": "gb-pvc-a1"},
"gb-pvc-a2": {"persistentVolumeClaimName": "gb-pvc-a2"},
"gb-pvc-b1": {"persistentVolumeClaimName": "gb-pvc-b1"},
"gb-pvc-b2": {"persistentVolumeClaimName": "gb-pvc-b2"},
},
},
"k8s.persistentvolumeclaim.name",
{"gb-pvc-a1", "gb-pvc-a2", "gb-pvc-b1", "gb-pvc-b2"},
id="pvc_name",
),
# Explicit groupBy=[k8s.namespace.name]: aggregated per namespace,
# persistentVolumeClaimName cleared, response grouped_list.
# groupBy=[k8s.namespace.name]: aggregated per namespace,
# persistentVolumeClaimName cleared (custom-groupBy branch).
pytest.param(
{
"fixture": "volumes_groupby.jsonl",
"group_by": "k8s.namespace.name",
"filter": None,
"group_meta_keys": ["k8s.namespace.name"],
"expected_type": "grouped_list",
"groups": {
"gb-ns-a": {"persistentVolumeClaimName": ""},
"gb-ns-b": {"persistentVolumeClaimName": ""},
},
},
"k8s.namespace.name",
{"gb-ns-a", "gb-ns-b"},
id="namespace",
),
# Default groupBy (no groupBy in request) => [k8s.persistentvolumeclaim.name,
# k8s.namespace.name, k8s.cluster.name] (module.go ListVolumes), response list.
# Same PVC name must NOT collapse across namespaces OR clusters; the
# empty-cluster group (k8s.cluster.name label absent on the source series)
# must appear as its own row with real metrics, not be dropped.
# Single series per group => SpaceAggregationSum == seeded value.
# Fails on the pre-cluster default (name+ns) — the three ns-x groups would
# collapse into one summed row.
pytest.param(
{
"fixture": "volumes_same_name_across_ns_and_clusters.jsonl",
"group_by": None,
"filter": "k8s.persistentvolumeclaim.name = 'dup-pvc'",
"group_meta_keys": ["k8s.persistentvolumeclaim.name", "k8s.namespace.name", "k8s.cluster.name"],
"expected_type": "list",
"groups": {
("dup-pvc", "ns-x", "cluster-a"): {
"persistentVolumeClaimName": "dup-pvc",
"volumeCapacity": 100.0,
"volumeAvailable": 60.0,
"volumeUsage": 40.0,
"volumeInodes": 1000.0,
"volumeInodesFree": 600.0,
"volumeInodesUsed": 400.0,
},
("dup-pvc", "ns-y", "cluster-a"): {
"persistentVolumeClaimName": "dup-pvc",
"volumeCapacity": 500.0,
"volumeAvailable": 100.0,
"volumeUsage": 400.0,
"volumeInodes": 5000.0,
"volumeInodesFree": 1000.0,
"volumeInodesUsed": 4000.0,
},
("dup-pvc", "ns-x", "cluster-b"): {
"persistentVolumeClaimName": "dup-pvc",
"volumeCapacity": 300.0,
"volumeAvailable": 50.0,
"volumeUsage": 250.0,
"volumeInodes": 3000.0,
"volumeInodesFree": 500.0,
"volumeInodesUsed": 2500.0,
},
# empty-cluster group: k8s.cluster.name label absent on the source series.
("dup-pvc", "ns-x", ""): {
"persistentVolumeClaimName": "dup-pvc",
"volumeCapacity": 200.0,
"volumeAvailable": 0.0,
"volumeUsage": 200.0,
"volumeInodes": 2000.0,
"volumeInodesFree": 0.0,
"volumeInodesUsed": 2000.0,
},
},
},
id="default_disambiguates_ns_and_cluster",
),
],
)
def test_volumes_groupby(
@@ -488,64 +400,51 @@ def test_volumes_groupby(
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
scenario: dict,
group_key: str,
expected_groups: set,
) -> None:
"""groupBy determines row identity. Explicit groupBy returns one grouped_list
record per distinct group (persistentVolumeClaimName populated only when
grouping by k8s.persistentvolumeclaim.name; volumes.go:26-29). With no groupBy
the default is [k8s.persistentvolumeclaim.name, k8s.namespace.name,
k8s.cluster.name] (module.go ListVolumes), so same-named PVCs across
namespaces/clusters stay as separate, un-collapsed list rows (incl. an
absent-cluster group keyed by ""). meta always surfaces the grouping key(s)."""
"""groupBy returns one record per distinct group. persistentVolumeClaimName
is populated only when grouping by k8s.persistentvolumeclaim.name
(volumes.go:26-29 list-vs-grouped branch); meta surfaces the groupBy key."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path(f"inframonitoring/{scenario['fixture']}"),
get_testdata_file_path("inframonitoring/volumes_groupby.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
body: dict = {
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
}
if scenario["group_by"] is not None:
body["groupBy"] = [{"name": scenario["group_by"], "fieldDataType": "string", "fieldContext": "resource"}]
if scenario["filter"] is not None:
body["filter"] = {"expression": scenario["filter"]}
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json=body,
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [
{
"name": group_key,
"fieldDataType": "string",
"fieldContext": "resource",
}
],
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert data["total"] == len(expected_groups)
groups = scenario["groups"]
meta_keys = scenario["group_meta_keys"]
assert data["type"] == scenario["expected_type"]
assert data["total"] == len(groups)
is_pvc_group = group_key == "k8s.persistentvolumeclaim.name"
group_of = lambda r: r["persistentVolumeClaimName"] if is_pvc_group else r["meta"][group_key] # noqa: E731 # pylint: disable=unnecessary-lambda-assignment
by_group = {group_of(r): r for r in data["records"]}
assert set(by_group.keys()) == expected_groups
def _gid(rec: dict):
vals = [rec["meta"][k] for k in meta_keys]
return vals[0] if len(vals) == 1 else tuple(vals)
by_group = {_gid(r): r for r in data["records"]}
assert set(by_group.keys()) == set(groups.keys())
for gid, exp in groups.items():
rec = by_group[gid]
for k in meta_keys:
assert k in rec["meta"], rec["meta"]
for field, val in exp.items():
if field in _GROUPBY_FLOAT_FIELDS:
assert compare_values(rec[field], val, 1e-6), f"{gid}.{field}: got {rec[field]}, expected {val}"
else:
assert rec[field] == val, f"{gid}.{field}: got {rec[field]}, expected {val}"
for group, rec in by_group.items():
# persistentVolumeClaimName populated per PVC when grouping by it, empty otherwise.
assert rec["persistentVolumeClaimName"] == (group if is_pvc_group else "")
assert group_key in rec["meta"], rec["meta"]
def test_volumes_pagination(