Compare commits

..

15 Commits

Author SHA1 Message Date
Ashwin Bhatkal
afce612caf feat: title in container 2026-06-11 21:13:54 +05:30
Ashwin Bhatkal
1ed852da93 Merge branch 'feat/dashboard-v2-variables' into feat/dashboard-v2-variable-selection 2026-06-11 21:06:54 +05:30
Ashwin Bhatkal
23931f25b6 Merge branch 'main' into feat/dashboard-v2-variables 2026-06-11 21:06:44 +05:30
Ashwin Bhatkal
e1ad451003 chore(dashboard-v2): apply formatter to variable selection slice 2026-06-11 19:07:46 +05:30
Ashwin Bhatkal
9155c635f8 refactor(dashboard-v2): use NewSelect for variable value pickers
Swap the value picker from @signozhq SelectSimple to the shared NewSelect
CustomSelect/CustomMultiSelect, which provide search, the ALL option and
apply-on-close batching (multi-select edits no longer cascade per toggle).
Deliberate exception to the @signozhq-first preference — reuses the existing,
richer variable-selection control.
2026-06-11 18:57:54 +05:30
Ashwin Bhatkal
870a4b3dc3 feat(dashboard-v2): scope dynamic variable options by sibling selections
Dynamic variables now pass an existingQuery built from the other dynamic
variables' current selections (e.g. `namespace IN ['prod']`) to the field-values
API, so related dynamic variables cascade-filter each other. Ported from the V1
runtime.
2026-06-11 18:57:54 +05:30
Naman Verma
7eb0095133 fix: proper definition of user dashboard preferences (#11643)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: proper definition of user dashboard preferences

* fix: use org id in deletion methods of pref table

* fix: make migration name fit regex

* fix: make compile return empty sql instead of nil

* fix: remove dashboard dependency from user module

* test: remove cleanup fixture from integration test
2026-06-11 12:50:32 +00:00
Naman Verma
df26eb1c1d chore: make some fields required in perses replicated spec (#11612)
* chore: make some fields required in perses replicated spec

* chore: build frondend spec

* revert: revert accidental change

* fix: make duration optional

* chore: add todo for duration and refresh interval
2026-06-11 12:17:25 +00:00
Ashwin Bhatkal
e9e7646403 Merge branch 'main' into feat/dashboard-v2-variables 2026-06-11 16:30:10 +05:30
Ashwin Bhatkal
736a2a751e feat(dashboard-v2): query & dynamic selectors with dependency orchestration
Query variables fetch options via /variables/query (passing the other variables'
values); Dynamic variables fetch live telemetry field values. Dependencies are
orchestrated declaratively: a Query selector is enabled only once its parents
resolve and its query key carries the parent values, so it refetches when a
parent changes and a cyclic dependency is simply never enabled. Options
auto-select the default/first value so dependent children always have input.
2026-06-11 11:52:00 +05:30
Ashwin Bhatkal
d01e8aff22 feat(dashboard-v2): variable selector bar with static selectors
Render a runtime variable bar above the panels (one control per spec variable),
seeding each value from URL -> localStorage(store) -> default and persisting
changes to the store + URL (?variables=). Custom (static options) and Text
selectors are wired; Query/Dynamic render the picker shell (option-fetching and
dependency orchestration follow).
2026-06-11 10:54:27 +05:30
Ashwin Bhatkal
6ae1033752 feat(dashboard-v2): variable-selection foundation — dependency graph & store
Pure runtime-selection groundwork (no UI yet): inter-variable dependency graph
(detect query references, topo order, cycle detection, transitive descendants)
ported to the V2 model; selection value types; and a persisted zustand
variableValues slice (frontend-only — selecting a value never patches the spec).
2026-06-11 10:31:31 +05:30
Ashwin Bhatkal
0587fe41e4 feat(dashboard-v2): variables settings tab — list, CRUD & persistence
Wire the Variables tab into the settings drawer (replacing the placeholder):
list variables, add/edit in place, delete via inline confirm, reorder, each
persisted with a single /spec/variables patch -> toast -> refetch.
2026-06-11 09:54:25 +05:30
Ashwin Bhatkal
75f5a8b82c feat(dashboard-v2): variable editor form for all variable types
In-drawer master-detail editor reproducing the V1 VariableItem layout with
@signozhq components: segmented type selector, per-type bodies (Custom comma
values, Text default + constant, Query editor + test-run preview, Dynamic
signal + field autocomplete) and the shared preview / sort / multi-select /
ALL / default-value rows.
2026-06-11 09:54:25 +05:30
Ashwin Bhatkal
2f541df60e feat(dashboard-v2): variable model, adapters & patch builder
Flat VariableFormModel + adapters between the nested envelope/plugin DTO union
(ListVariable{Query,Custom,Dynamic} / TextVariable) and the model, plus a
JSON-patch builder that replaces /spec/variables atomically. Pure, no UI.
2026-06-11 09:54:25 +05:30
71 changed files with 3421 additions and 491 deletions

View File

@@ -2436,13 +2436,6 @@ components:
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2570,13 +2563,12 @@ components:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
type: object
display:
$ref: '#/components/schemas/CommonDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/DashboardtypesLayout'
nullable: true
type: array
links:
items:
@@ -2585,7 +2577,6 @@ components:
panels:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesPanel'
nullable: true
type: object
refreshInterval:
type: string
@@ -2593,6 +2584,11 @@ components:
items:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
required:
- display
- variables
- panels
- layouts
type: object
DashboardtypesDatasourcePlugin:
discriminator:
@@ -2628,6 +2624,15 @@ components:
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDisplay:
properties:
description:
type: string
name:
type: string
required:
- name
type: object
DashboardtypesDynamicVariableSpec:
properties:
name:
@@ -2822,7 +2827,7 @@ components:
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/VariableDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
name:
type: string
plugin:
@@ -2830,6 +2835,8 @@ components:
sort:
nullable: true
type: string
required:
- display
type: object
DashboardtypesListableDashboardForUserV2:
properties:
@@ -2957,7 +2964,7 @@ components:
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/CommonDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
@@ -2977,6 +2984,9 @@ components:
$ref: '#/components/schemas/DashboardtypesPanelKind'
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelFormatting:
properties:
@@ -3106,7 +3116,7 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardPanelDisplay'
$ref: '#/components/schemas/DashboardtypesDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
@@ -3116,7 +3126,12 @@ components:
queries:
items:
$ref: '#/components/schemas/DashboardtypesQuery'
nullable: true
type: array
required:
- display
- plugin
- queries
type: object
DashboardtypesPatchOp:
enum:
@@ -3185,6 +3200,9 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPlugin:
discriminator:
@@ -3291,6 +3309,8 @@ components:
type: string
plugin:
$ref: '#/components/schemas/DashboardtypesQueryPlugin'
required:
- plugin
type: object
DashboardtypesQueryVariableSpec:
properties:

View File

@@ -254,12 +254,12 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, userID)
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {

View File

@@ -3156,17 +3156,6 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3892,6 +3881,17 @@ export type DashboardtypesDashboardSpecDTODatasources = {
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
export interface DashboardtypesDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4440,42 +4440,36 @@ export interface DashboardtypesQuerySpecDTO {
* @type string
*/
name?: string;
plugin?: DashboardtypesQueryPluginDTO;
plugin: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
kind?: Querybuildertypesv5RequestTypeDTO;
spec?: DashboardtypesQuerySpecDTO;
kind: Querybuildertypesv5RequestTypeDTO;
spec: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display?: DashboardPanelDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
plugin?: DashboardtypesPanelPluginDTO;
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
* @type array,null
*/
queries?: DashboardtypesQueryDTO[];
queries: DashboardtypesQueryDTO[] | null;
}
export interface DashboardtypesPanelDTO {
kind?: DashboardtypesPanelKindDTO;
spec?: DashboardtypesPanelSpecDTO;
kind: DashboardtypesPanelKindDTO;
spec: DashboardtypesPanelSpecDTO;
}
export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
export type DashboardtypesDashboardSpecDTOPanels = {
[key: string]: DashboardtypesPanelDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
@@ -4572,7 +4566,7 @@ export interface DashboardtypesListVariableSpecDTO {
*/
customAllValue?: string;
defaultValue?: VariableDefaultValueDTO;
display?: VariableDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
@@ -4614,23 +4608,23 @@ export interface DashboardtypesDashboardSpecDTO {
* @type object
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display?: CommonDisplayDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
*/
duration?: string;
/**
* @type array,null
* @type array
*/
layouts?: DashboardtypesLayoutDTO[] | null;
layouts: DashboardtypesLayoutDTO[];
/**
* @type array
*/
links?: DashboardLinkDTO[];
/**
* @type object,null
* @type object
*/
panels?: DashboardtypesDashboardSpecDTOPanels;
panels: DashboardtypesDashboardSpecDTOPanels;
/**
* @type string
*/
@@ -4638,7 +4632,7 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
variables?: DashboardtypesVariableDTO[];
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesDatasourcePluginKindDTO {
@@ -4762,7 +4756,7 @@ export enum DashboardtypesListSortDTO {
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: CommonDisplayDTO;
display?: DashboardtypesDisplayDTO;
}
export interface DashboardtypesListedDashboardForUserV2DTO {

View File

@@ -36,7 +36,6 @@ export const REACT_QUERY_KEY = {
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',

View File

@@ -1,42 +0,0 @@
import { getFlamegraph } from 'api/generated/services/tracedetail';
import {
SpantypesGettableFlamegraphTraceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface GetTraceFlamegraphV3Props {
traceId: string;
selectedSpanId?: string;
selectFields?: TelemetryFieldKey[];
enabled?: boolean;
}
const useGetTraceFlamegraphV3 = (
props: GetTraceFlamegraphV3Props,
): UseQueryResult<SpantypesGettableFlamegraphTraceDTO, unknown> =>
useQuery({
queryFn: () =>
getFlamegraph(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
selectFields: props.selectFields as TelemetrytypesTelemetryFieldKeyDTO[],
},
).then((res) => res.data),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
props.selectFields,
],
enabled: props.enabled,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
export default useGetTraceFlamegraphV3;

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
function DynamicVariableFields({
attribute,
signal,
onChange,
onPreview,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// 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) => ({
label: name,
value: name,
})),
[keyData],
);
const { data: valueData } = useGetFieldValues({
signal,
name: attribute,
enabled: !!attribute,
});
useEffect(() => {
const payload = valueData?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
onPreview(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
}
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>
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
</>
);
}
export default DynamicVariableFields;

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
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 sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
}
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
onChange,
onPreview,
onError,
}: QueryVariableFieldsProps): JSX.Element {
const [isRunning, setIsRunning] = useState(false);
const runTest = async (): Promise<void> => {
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);
}
};
return (
<div className={styles.queryContainer}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Query</Typography.Text>
</div>
<div className={styles.editorWrap}>
<Editor
language="sql"
value={queryValue}
onChange={(value): void => onChange(value)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
</div>
<div className={styles.testRow}>
<Button
variant="solid"
color="primary"
size="sm"
loading={isRunning}
disabled={!queryValue}
onClick={runTest}
testId="variable-test-run"
>
Test Run Query
</Button>
</div>
</div>
);
}
export default QueryVariableFields;

View File

@@ -0,0 +1,310 @@
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
built on @signozhq components where possible. antd is retained only for the
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.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 {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px 20px;
}
/* VariableItemRow */
.row {
display: flex;
gap: 1rem;
margin-bottom: 0;
}
/* LabelContainer */
.labelContainer {
width: 200px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.column {
flex-direction: column;
gap: 8px;
}
.input,
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
.input,
.textarea {
width: 100%;
}
.defaultInput {
width: 342px;
}
.errorText {
font-size: 12px;
color: var(--bg-amber-500);
}
/* Variable type segmented group */
.typeSection {
align-items: center;
justify-content: space-between;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
}
.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;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
margin-left: 4px;
}
/* Query */
.queryContainer {
display: flex;
flex-flow: column wrap;
gap: 1rem;
min-width: 0;
margin-bottom: 0;
}
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.testRow {
display: flex;
margin-top: 8px;
}
/* Custom — antd Collapse */
.customSection {
margin-bottom: 0;
}
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
border-bottom: none;
}
:global(.ant-collapse-header) {
align-items: center;
gap: 8px;
height: 38px;
padding: 12px;
background: var(--l3-background);
border-radius: 3px 3px 0 0;
}
:global(.ant-collapse-header-text) {
display: flex;
align-items: center;
gap: 10px;
padding: 1px 2px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
:global(.ant-collapse-content-box) {
padding: 0;
}
:global(.comma-input) {
height: 109px;
border: none;
}
}
/* Textbox */
.textboxSection {
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
/* Preview strip */
.previewSection {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.previewLabel {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 3px 0 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.previewValues {
display: flex;
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
overflow-y: auto;
}
.previewValues [data-slot='badge'] {
height: 30px;
align-items: center;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.previewError {
color: var(--bg-amber-500);
}
/* Sort / multi / all / default rows */
.sortSection,
.multiSection,
.allOptionSection,
.dynamicSection {
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0;
}
.sortSection {
align-items: center;
}
.rowLabel {
width: 339px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
}
.defaultValueSection .label {
display: block;
margin-bottom: 2px;
}
.defaultValueDesc {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
line-height: 18px;
letter-spacing: -0.06px;
}
.searchSelect {
width: 100%;
}
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -0,0 +1,351 @@
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 { 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/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 {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
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.
*/
function VariableForm({
initial,
existingNames,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
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,
);
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}>
<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}>
{/* 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>
{/* 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>
{/* 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"
/>
),
},
]}
/>
</div>
) : null}
{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"
/>
</div>
) : null}
{/* 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>
<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>
<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>
</>
) : null}
</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>
</>
);
}
export default VariableForm;

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

@@ -0,0 +1,101 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.titleRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--l1-foreground);
}
.subtitle {
font-size: 12px;
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;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.rowMain {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
}
.rowActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,139 @@
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
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;
/** Index whose delete is awaiting inline confirmation, if any. */
confirmingIndex: number | null;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
}
function VariablesList({
variables,
canEdit,
confirmingIndex,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
return (
<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>
))}
</div>
);
}
export default VariablesList;

View File

@@ -0,0 +1,147 @@
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 { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
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 initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
return null;
}
return editing.index === null
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
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 = (model: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
}
setEditing(null);
persist(next);
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onSave={handleFormSave}
/>
);
}
// Master view — the variables list.
return (
<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 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
) : (
<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>
);
}
export default VariablesSettings;

View File

@@ -0,0 +1,51 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {
save: (variables: VariableFormModel[]) => Promise<boolean>;
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
const save = useCallback(
async (variables: VariableFormModel[]): Promise<boolean> => {
if (!dashboardId) {
return false;
}
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };
}

View File

@@ -0,0 +1,154 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} 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 common: VariableFormModel = {
...base,
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
hidden: display?.hidden ?? false,
};
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
textValue: spec.value ?? '',
textConstant: spec.constant ?? false,
};
}
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const listCommon: VariableFormModel = {
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
return {
...listCommon,
type: 'CUSTOM',
customValue: plugin.spec.customValue ?? '',
};
}
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
return {
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
return {
...listCommon,
type: 'QUERY',
queryValue:
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
? (plugin.spec.queryValue ?? '')
: '',
};
}
function buildPlugin(
model: VariableFormModel,
): DashboardtypesVariablePluginDTO {
switch (model.type) {
case 'CUSTOM':
return {
kind: CustomPluginKind['signoz/CustomVariable'],
spec: { customValue: model.customValue },
};
case 'DYNAMIC':
return {
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
default:
return {
kind: QueryPluginKind['signoz/QueryVariable'],
spec: { queryValue: model.queryValue },
};
}
}
/** Flat form model → DTO envelope (for persistence). */
export function formModelToDto(
model: VariableFormModel,
): DashboardtypesVariableDTO {
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
return {
kind: TextEnvelopeKind.TextVariable,
spec: {
name: model.name,
display,
value: model.textValue,
constant: model.textConstant,
},
};
}
return {
kind: ListEnvelopeKind.ListVariable,
spec: {
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
},
};
}
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
export function variableTypeOf(
dto: DashboardtypesVariableDTO,
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

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

@@ -0,0 +1,22 @@
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
* member sets-or-replaces, so it works whether or not `variables` already exists.
*/
export function buildVariablesPatch(
variables: DashboardtypesVariableDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
return [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: variables,
},
];
}

View File

@@ -1,48 +1,39 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { Tabs } from '@signozhq/ui/tabs';
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import styles from './DashboardSettings.module.scss';
import VariablesSettings from './Variables';
interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
}
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items = useMemo(
const items: TabItemProps[] = useMemo(
() => [
{
key: 'general',
label: tabLabel(<Table size={14} />, 'General'),
label: 'General',
children: <GeneralSettings dashboard={dashboard} />,
prefixIcon: <Table size={14} />,
},
{
key: 'variables',
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
label: 'Variables',
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
},
{
key: 'public-dashboard',
label: tabLabel(<Globe size={14} />, 'Publish'),
label: 'Publish',
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
prefixIcon: <Globe size={14} />,
},
],
[dashboard],

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
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'
? sortValues(commaValuesParser(variable.customValue), variable.sort).map(
String,
)
: [],
[variable],
);
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
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.variable} data-testid={`variable-${variable.name}`}>
<Typography.Text className={styles.label}>${variable.name}</Typography.Text>
{renderControl()}
</div>
);
}
export default VariableSelector;

View File

@@ -0,0 +1,29 @@
.bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--l1-border);
}
.variable {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
.select {
min-width: 160px;
}
.input {
min-width: 160px;
}

View File

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,56 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
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

@@ -0,0 +1,16 @@
/** 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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,79 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
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: 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 sortValues(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

@@ -0,0 +1,89 @@
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 sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableModel';
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 sortValues(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

@@ -0,0 +1,31 @@
import { Input } from '@signozhq/ui/input';
import type { VariableSelection } from '../selectionTypes';
import styles from '../VariablesBar.module.scss';
interface TextSelectorProps {
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
testId?: string;
}
/** Free-text variable input. */
function TextSelector({
selection,
onChange,
testId,
}: TextSelectorProps): JSX.Element {
return (
<Input
className={styles.input}
value={typeof selection.value === 'string' ? selection.value : ''}
placeholder="Enter a value"
onChange={(e): void =>
onChange({ value: e.target.value, allSelected: false })
}
testId={testId}
/>
);
}
export default TextSelector;

View File

@@ -0,0 +1,94 @@
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.select}
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

@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
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

@@ -0,0 +1,116 @@
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/variableModel';
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

@@ -0,0 +1,199 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableModel';
/**
* 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

@@ -9,6 +9,7 @@ import { useAppContext } from 'providers/App/App';
import DashboardDescription from './DashboardDescription';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardStore } from './store/useDashboardStore';
import VariablesBar from './VariablesBar/VariablesBar';
import styles from './DashboardContainer.module.scss';
interface DashboardContainerProps {
@@ -20,6 +21,10 @@ function DashboardContainer({
dashboard,
refetch,
}: DashboardContainerProps): JSX.Element {
useEffect(() => {
document.title = dashboard.name
}, [dashboard.name])
const fullScreenHandle = useFullScreenHandle();
const { user } = useAppContext();
@@ -45,6 +50,7 @@ function DashboardContainer({
handle={fullScreenHandle}
refetch={refetch}
/>
<VariablesBar dashboard={dashboard} />
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
</div>
{/* Shared panel-type picker (V1 component): opened from any "New Panel"

View File

@@ -0,0 +1,55 @@
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,25 +9,36 @@ import {
createCollapseSlice,
type CollapseSlice,
} from './slices/collapseSlice';
import {
createVariableSelectionSlice,
type VariableSelectionSlice,
} from './slices/variableSelectionSlice';
export type DashboardStore = EditContextSlice & CollapseSlice;
export type DashboardStore = EditContextSlice &
CollapseSlice &
VariableSelectionSlice;
/**
* V2 dashboard session store. Holds cross-cutting client state only — never the
* dashboard spec (that stays in react-query via useGetDashboardV2). Two slices:
* dashboard spec (that stays in react-query via useGetDashboardV2). 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 only the collapse map — context (incl. the refetch fn) is transient.
partialize: (state) => ({ collapsed: state.collapsed }),
// Persist UI-only state (context incl. the refetch fn is transient).
partialize: (state) => ({
collapsed: state.collapsed,
variableValues: state.variableValues,
}),
},
),
);

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
@@ -16,13 +15,6 @@ function DashboardPageV2(): JSX.Element {
});
const dashboard = data?.data;
const name = dashboard?.spec?.display?.name;
useEffect(() => {
if (name) {
document.title = name;
}
}, [name]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -133,6 +133,10 @@ function DashboardsList(): JSX.Element {
tags: null,
spec: {
display: { name: t('new_dashboard_title', { ns: 'dashboard' }) },
layouts: [],
panels: {},
variables: [],
// TODO(@AshwinBhatkal): duration and refresh interval need to be integrated
},
});
safeNavigate(

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { SpanV3 } from 'types/api/trace/getTraceV3';
@@ -53,9 +53,6 @@ function TraceFlamegraph({
);
const previewFields = useTraceStore((s) => s.previewFields);
// Gate the fetch until prefs load, else selectFields (in the query key)
// repopulates and triggers a second fetch.
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
// color-by entries first so their canonical metadata wins on collision.
@@ -73,14 +70,17 @@ function TraceFlamegraph({
data,
isFetching,
error: fetchError,
} = useGetTraceFlamegraphV3({
} = useGetTraceFlamegraph({
traceId,
selectedSpanId: selectedSpanIdForFetch,
limit: FLAMEGRAPH_SPAN_LIMIT,
selectFields: flamegraphSelectFields,
enabled: !!traceId && userPrefsReady,
});
const spans = useMemo(() => data?.spans || [], [data?.spans]);
const spans = useMemo(
() => data?.payload?.spans || [],
[data?.payload?.spans],
);
const {
layout,
@@ -99,8 +99,8 @@ function TraceFlamegraph({
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.startTimestampMillis || 0,
endTime: data?.endTimestampMillis || 0,
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
@@ -124,7 +124,7 @@ function TraceFlamegraph({
if (fetchError || workerError) {
return <Error error={(fetchError || workerError) as any} />;
}
if (data?.spans && data.spans.length === 0) {
if (data?.payload?.spans && data.payload.spans.length === 0) {
return <div>No data found for trace {traceId}</div>;
}
return (
@@ -134,17 +134,17 @@ function TraceFlamegraph({
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.startTimestampMillis || 0,
endTime: data?.endTimestampMillis || 0,
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
);
}, [
data?.endTimestampMillis,
data?.startTimestampMillis,
data?.spans,
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
data?.payload?.spans,
fetchError,
filteredSpanIds,
firstSpanAtFetchLevel,

View File

@@ -1,12 +1,12 @@
import { render } from '@testing-library/react';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import { AllTheProviders } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { FLAMEGRAPH_SPAN_LIMIT } from '../constants';
import TraceFlamegraph from '../TraceFlamegraph';
jest.mock('hooks/trace/useGetTraceFlamegraphV3');
jest.mock('hooks/trace/useGetTraceFlamegraph');
// Short-circuit the worker so the test doesn't depend on layout computation.
jest.mock('../hooks/useVisualLayoutWorker', () => ({
@@ -17,8 +17,9 @@ jest.mock('../hooks/useVisualLayoutWorker', () => ({
}),
}));
const mockUseGetTraceFlamegraph =
useGetTraceFlamegraphV3 as jest.MockedFunction<typeof useGetTraceFlamegraphV3>;
const mockUseGetTraceFlamegraph = useGetTraceFlamegraph as jest.MockedFunction<
typeof useGetTraceFlamegraph
>;
function renderFlamegraph(props: {
selectedSpan: SpanV3 | undefined;
@@ -44,7 +45,7 @@ describe('TraceFlamegraph - selectedSpanId pass-through', () => {
beforeEach(() => {
mockUseGetTraceFlamegraph.mockReset();
mockUseGetTraceFlamegraph.mockReturnValue({
data: { spans: [] },
data: { payload: { spans: [] } },
isFetching: false,
error: null,
} as never);

View File

@@ -1,4 +1,4 @@
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
computeVisualLayout,
@@ -14,12 +14,12 @@ function makeSpan(
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',
level: 0,
event: [],
resource: {},
attributes: {},
...overrides,
};
}

View File

@@ -1,4 +1,4 @@
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
/** Minimal FlamegraphSpan for unit tests */
export const MOCK_SPAN: FlamegraphSpan = {
@@ -6,12 +6,12 @@ export const MOCK_SPAN: FlamegraphSpan = {
durationNano: 50_000_000, // 50ms
spanId: 'span-1',
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'test-service',
name: 'test-span',
level: 0,
event: [],
resource: {},
attributes: {},
};
/** Nested spans structure for findSpanById tests */

View File

@@ -65,25 +65,37 @@ describe('Presentation / Styling Utils', () => {
describe('getFlamegraphSpanGroupValue', () => {
it('returns resource[field.name] when present', () => {
const value = getFlamegraphSpanGroupValue(
{ resource: { 'service.name': 'svc-from-resource' } },
{
serviceName: 'legacy',
resource: { 'service.name': 'svc-from-resource' },
},
SERVICE_FIELD,
);
expect(value).toBe('svc-from-resource');
});
it('returns "unknown" for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue({ resource: {} }, SERVICE_FIELD);
expect(value).toBe('unknown');
it('falls back to top-level serviceName for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc-legacy', resource: {} },
SERVICE_FIELD,
);
expect(value).toBe('svc-legacy');
});
it('returns "unknown" for non-service fields when resource is missing', () => {
const value = getFlamegraphSpanGroupValue({ resource: {} }, HOST_FIELD);
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc', resource: {} },
HOST_FIELD,
);
expect(value).toBe('unknown');
});
it('reads host.name from resource when present', () => {
const value = getFlamegraphSpanGroupValue(
{ resource: { 'host.name': 'host-1' } },
{
serviceName: 'svc',
resource: { 'host.name': 'host-1' },
},
HOST_FIELD,
);
expect(value).toBe('host-1');

View File

@@ -1,10 +1,11 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
export interface ConnectorLine {
parentRow: number;
childRow: number;
timestampMs: number;
serviceName: string;
// Snapshot of the child span's resource so draw-time can resolve the
// `colorByField` group value without crossing the worker boundary.
resource?: Record<string, string>;
@@ -158,8 +159,24 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
}
}
// Extract parentSpanId — the field may be missing at runtime when the API
// returns `references` instead. Fall back to the first CHILD_OF reference.
function getParentId(span: FlamegraphSpan): string {
return span.parentSpanId || '';
if (span.parentSpanId) {
return span.parentSpanId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refs = (span as any).references as
| Array<{ spanId?: string; refType?: string }>
| undefined;
if (refs) {
for (const ref of refs) {
if (ref.refType === 'CHILD_OF' && ref.spanId) {
return ref.spanId;
}
}
}
return '';
}
// Build children map and identify roots
@@ -463,6 +480,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
parentRow,
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
resource: child.resource,
});
}

View File

@@ -1,7 +1,7 @@
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ConnectorLine } from '../computeVisualLayout';
@@ -200,7 +200,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
}
const groupValue = getFlamegraphSpanGroupValue(
{ resource: conn.resource },
{ serviceName: conn.serviceName, resource: conn.resource },
colorByField,
);
const pair = generateColorPair(groupValue);

View File

@@ -11,9 +11,10 @@ import {
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { EventRect, ITraceMetadata, SpanRect } from '../types';
import { EventRect, SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import {
getFlamegraphServiceName,
getFlamegraphSpanGroupValue,
@@ -199,7 +200,7 @@ export function useFlamegraphHover(
if (eventRect) {
const { event, span } = eventRect;
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
const eventTimeMs = event.timeUnixNano / 1e6;
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
@@ -219,10 +220,10 @@ export function useFlamegraphHover(
return isDarkMode ? pair.color : pair.colorDark;
})(),
event: {
name: event.name ?? '',
name: event.name,
timeOffsetMs: eventTimeMs - span.timestamp,
isError: event.isError ?? false,
attributeMap: (event.attributeMap as Record<string, string>) ?? {},
isError: event.isError,
attributeMap: event.attributeMap || {},
},
});
updateCursor(canvas, eventRect.span);

View File

@@ -5,7 +5,7 @@ import {
SetStateAction,
useEffect,
} from 'react';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { MIN_VISIBLE_SPAN_MS } from '../constants';
import { ITraceMetadata } from '../types';

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';

View File

@@ -1,8 +1,5 @@
import {
SpantypesEventDTO as FlamegraphEvent,
SpantypesFlamegraphSpanDTO as FlamegraphSpan,
} from 'api/generated/services/sigNoz.schemas';
import { Dispatch, SetStateAction } from 'react';
import { Event, FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';
@@ -31,7 +28,7 @@ export interface SpanRect {
}
export interface EventRect {
event: FlamegraphEvent;
event: Event;
span: FlamegraphSpan;
cx: number;
cy: number;

View File

@@ -7,7 +7,7 @@ import {
generateColorPair,
RESERVED_ERROR,
} from 'pages/TraceDetailsV3/utils/generateColorPair';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
@@ -74,25 +74,34 @@ export function getFlamegraphRowMetrics(
/**
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
* (service identity, independent of the active colour-by field). Reads
* `resource['service.name']`.
* (service identity, independent of the active colour-by field). Prefers
* `resource['service.name']` with legacy top-level `serviceName` fallback.
*/
export function getFlamegraphServiceName(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
): string {
return getSpanAttribute(span, 'service.name') || '';
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
}
/**
* Resolve the value used to bucket a flamegraph span by colour for the given
* field. Prefers `resource[field.name]` (contract from `selectFields`), falling
* back to `'unknown'`.
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
* For `service.name`, falls back to the legacy top-level `serviceName` when
* resource is empty (backward-compat with backends that haven't shipped
* `selectFields` yet). For other fields, falls back to `'unknown'`.
*/
export function getFlamegraphSpanGroupValue(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
field: TelemetryFieldKey,
): string {
return getSpanAttribute(span, field.name) || 'unknown';
const fromAttribute = getSpanAttribute(span, field.name);
if (fromAttribute) {
return fromAttribute;
}
if (field.name === 'service.name') {
return span.serviceName || 'unknown';
}
return 'unknown';
}
interface GetSpanColorArgs {
@@ -287,7 +296,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
return;
}
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
@@ -297,11 +306,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Event dots derive from the effective bar color so they track the
// light/dark variant the bar is rendered with.
const parentBarColor = isDarkMode ? color : colorDark;
const dotColor = getEventDotColor(
parentBarColor,
event.isError ?? false,
isDarkMode,
);
const dotColor = getEventDotColor(parentBarColor, event.isError, isDarkMode);
const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`;
const isEventHovered = hoveredEventKey === eventKey;
const dotSize = isEventHovered

View File

@@ -1,4 +1,4 @@
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';

View File

@@ -73,11 +73,11 @@ type Module interface {
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}
type Handler interface {

View File

@@ -13,9 +13,16 @@ type Compiled struct {
Args []any
}
func (c Compiled) IsEmpty() bool {
return c.SQL == ""
}
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
return nil, nil //nolint:nilnil
return &Compiled{}, nil
}
queryVisitor := newVisitor(formatter)
@@ -29,9 +36,6 @@ func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
if sql == "" {
return nil, nil //nolint:nilnil
}
return &Compiled{
SQL: sql,

View File

@@ -17,7 +17,7 @@ import (
type compileCase struct {
subtestName string
dslQueryToCompile string
nilExpected bool
emptyQueryExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
@@ -41,8 +41,8 @@ func runCompileCases(t *testing.T, cases []compileCase) {
}
require.NoError(t, err)
if c.nilExpected {
assert.Nil(t, out)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
return
}
require.NotNil(t, out)
@@ -71,7 +71,7 @@ func runCompileCases(t *testing.T, cases []compileCase) {
func TestCompile_Empty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
})
}

View File

@@ -103,7 +103,7 @@ func (store *store) ListForUser(
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if compiled != nil {
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
@@ -166,7 +166,7 @@ func (store *store) ListV2(
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if compiled != nil {
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
@@ -383,15 +383,16 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
// rows = 0 is the only signal of a real limit hit.
func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.UserDashboardPreference) error {
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
INSERT INTO user_dashboard_preference (user_id, dashboard_id, is_pinned)
SELECT ?, ?, true
INSERT INTO user_dashboard_preference (id, user_id, dashboard_id, is_pinned, created_at, updated_at)
SELECT ?, ?, ?, true, ?, ?
WHERE (SELECT COUNT(*) FROM user_dashboard_preference WHERE user_id = ? AND is_pinned = true) < ?
OR EXISTS (SELECT 1 FROM user_dashboard_preference WHERE user_id = ? AND dashboard_id = ? AND is_pinned = true)
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET is_pinned = true, updated_at = ?
`,
preference.UserID, preference.DashboardID,
preference.ID, preference.UserID, preference.DashboardID, preference.CreatedAt, preference.UpdatedAt,
preference.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
preference.UserID, preference.DashboardID,
preference.UpdatedAt,
).Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't pin dashboard for user")
@@ -410,12 +411,21 @@ func (store *store) PinForUser(ctx context.Context, preference *dashboardtypes.U
// UnpinForUser deletes the user's preference row. This is fine while is_pinned
// is the only preference stored; once the row carries other preferences this
// must become an UPDATE that clears is_pinned instead of dropping the row.
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
func (store *store) UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't unpin dashboard for user")
@@ -423,11 +433,19 @@ func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashbo
return nil
}
func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error {
func (store *store) DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
dashboardIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("dashboard").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("dashboard_id = ?", dashboardID).
Where("dashboard_id IN (?)", dashboardIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")
@@ -435,11 +453,19 @@ func (store *store) DeletePreferencesForDashboard(ctx context.Context, dashboard
return nil
}
func (store *store) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
func (store *store) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
// No org_id on the preference table, so scope by org via a subquery on the
// parent (DELETE-with-JOIN isn't portable across Postgres/SQLite).
userIDsInOrgSubQuery := store.sqlstore.BunDBCtx(ctx).
NewSelect().
TableExpr("users").
Column("id").
Where("org_id = ?", orgID)
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.UserDashboardPreference)(nil)).
Where("user_id = ?", userID).
Where("user_id IN (?)", userIDsInOrgSubQuery).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete dashboard preferences")

View File

@@ -304,7 +304,7 @@ func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin
if pin {
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
} else {
err = handler.module.UnpinV2(ctx, userID, dashboardID)
err = handler.module.UnpinV2(ctx, orgID, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)

View File

@@ -119,7 +119,7 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
if err := existing.ErrIfNotUpdatable(); err != nil {
return nil, err
}
@@ -154,7 +154,7 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
if err := existing.ErrIfNotUpdatable(); err != nil {
return nil, err
}
@@ -193,7 +193,7 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
if err != nil {
return err
}
if err := existing.CanDelete(); err != nil {
if err := existing.ErrIfNotDeletable(); err != nil {
return err
}
@@ -202,7 +202,7 @@ func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
return err
}
if err := module.store.DeletePreferencesForDashboard(ctx, id); err != nil {
if err := module.store.DeletePreferencesForDashboard(ctx, orgID, id); err != nil {
return err
}
return module.store.Delete(ctx, orgID, id)
@@ -231,10 +231,10 @@ func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID value
return module.store.PinForUser(ctx, dashboardtypes.NewUserDashboardPreference(userID, id))
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, userID, id)
func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, orgID, userID, id)
}
func (module *module) DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, userID)
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}

View File

@@ -182,7 +182,7 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start, summary.End, false), nil
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
@@ -209,6 +209,10 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
return nil, err
}
enrichedSpans := flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, summary.Start, summary.End, true), nil
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
summary.Start.UnixMilli(),
summary.End.UnixMilli(),
true,
), nil
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/tokenizer"
@@ -35,11 +34,11 @@ type setter struct {
analytics analytics.Analytics
config root.Config
getter root.Getter
dashboard dashboard.Module
onDeleteUser []root.OnDeleteUser
}
// This module is a WIP, don't take inspiration from this.
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, dashboard dashboard.Module) root.Setter {
func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config, userRoleStore authtypes.UserRoleStore, getter root.Getter, onDeleteUser []root.OnDeleteUser) root.Setter {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &setter{
store: store,
@@ -52,7 +51,7 @@ func NewSetter(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
authz: authz,
config: config,
getter: getter,
dashboard: dashboard,
onDeleteUser: onDeleteUser,
}
}
@@ -409,8 +408,10 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if err := module.dashboard.DeletePreferencesForUser(ctx, user.ID); err != nil {
return err
for _, onDeleteUser := range module.onDeleteUser {
if err := onDeleteUser(ctx, orgID, user.ID); err != nil {
return err
}
}
traitsOrProperties := types.NewTraitsFromUser(user)

View File

@@ -129,3 +129,6 @@ type Handler interface {
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
}
// OnDeleteUser lets other modules clean up data tied to a deleted user.
type OnDeleteUser func(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error

View File

@@ -122,7 +122,11 @@ func NewModules(
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, dashboard)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
}
userSetter := impluser.NewSetter(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User, userRoleStore, userGetter, onDeleteUser)
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{

View File

@@ -212,6 +212,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,84 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type recreateUserDashboardPreference struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewRecreateUserDashboardPreferenceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("recreate_user_dashboard_pref"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &recreateUserDashboardPreference{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *recreateUserDashboardPreference) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up replaces the composite (user_id, dashboard_id) primary key with a surrogate
// id primary key, demotes the pair to a unique index, and adds created_at /
// updated_at. The table is dropped and recreated since it carries no data yet.
func (migration *recreateUserDashboardPreference) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().DropTable(&sqlschema.Table{Name: "user_dashboard_preference"})
sqls = append(sqls, migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "user_dashboard_preference",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "is_pinned", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "false"},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("user_id"),
ReferencedTableName: sqlschema.TableName("users"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "user_dashboard_preference",
ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"},
})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *recreateUserDashboardPreference) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
)
const (
@@ -110,7 +109,7 @@ type listedDashboardV2 struct {
}
type listedDashboardV2Spec struct {
Display *common.Display `json:"display,omitempty"`
Display Display `json:"display,omitempty"`
}
func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"k8s.io/apimachinery/pkg/util/validation"
)
@@ -62,10 +61,17 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) CanUpdate() error {
func (d *DashboardV2) ErrIfNotMutable() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
return nil
}
func (d *DashboardV2) ErrIfNotUpdatable() error {
if err := d.ErrIfNotMutable(); err != nil {
return err
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
@@ -73,7 +79,7 @@ func (d *DashboardV2) CanUpdate() error {
}
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
if err := d.ErrIfNotUpdatable(); err != nil {
return err
}
if updatable.Name != d.Name {
@@ -87,7 +93,7 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
func (d *DashboardV2) ErrIfNotLockable(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
@@ -101,7 +107,7 @@ func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
if err := d.ErrIfNotLockable(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
@@ -110,7 +116,7 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) CanDelete() error {
func (d *DashboardV2) ErrIfNotDeletable() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
@@ -168,9 +174,6 @@ func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
if p.Spec.Display == nil {
p.Spec.Display = &common.Display{}
}
if !p.GenerateName && p.Spec.Display.Name == "" {
p.Spec.Display.Name = p.Name
}
@@ -197,7 +200,7 @@ func (p *PostableDashboardV2) validateName() error {
if p.Name != "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name must be empty when generateName is true, got %q", p.Name)
}
if p.Spec.Display == nil || p.Spec.Display.Name == "" {
if p.Spec.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.display.name is required when generateName is true")
}
return nil
@@ -341,9 +344,6 @@ func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdatableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}

View File

@@ -8,10 +8,9 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -166,7 +165,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
DashboardV2MetadataBase: DashboardV2MetadataBase{SchemaVersion: SchemaVersion},
GenerateName: true,
Spec: DashboardSpec{
Display: &common.Display{Name: "My Dashboard!"},
Display: Display{Name: "My Dashboard!"},
},
}

View File

@@ -17,12 +17,12 @@ import (
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardSpec struct {
Display *common.Display `json:"display,omitempty"`
Display Display `json:"display" required:"true"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
Panels map[string]*Panel `json:"panels"`
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
Variables []Variable `json:"variables" required:"true" nullable:"false"`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration,omitempty"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
}

View File

@@ -18,8 +18,8 @@ import (
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind"`
Spec any `json:"spec"`
Kind PanelPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// PrepareJSONSchema marks the envelope with x-signoz-discriminator;
@@ -81,8 +81,8 @@ func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind"`
Spec any `json:"spec"`
Kind QueryPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -139,8 +139,8 @@ func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind"`
Spec any `json:"spec"`
Kind VariablePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -191,8 +191,8 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind"`
Spec any `json:"spec"`
Kind DatasourcePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {

View File

@@ -13,6 +13,11 @@ import (
"github.com/swaggest/jsonschema-go"
)
type Display struct {
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
@@ -28,8 +33,8 @@ type DatasourceSpec struct {
// ══════════════════════════════════════════════
type Panel struct {
Kind PanelKind `json:"kind"`
Spec PanelSpec `json:"spec"`
Kind PanelKind `json:"kind" required:"true"`
Spec PanelSpec `json:"spec" required:"true"`
}
// PanelKind is the panel envelope discriminator. Perses leaves it a free
@@ -54,10 +59,10 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
}
type PanelSpec struct {
Display *dashboard.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []dashboard.Link `json:"links,omitempty"`
Display Display `json:"display" required:"true"`
Plugin PanelPlugin `json:"plugin" required:"true"`
Queries []Query `json:"queries" required:"true"`
Links []dashboard.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
@@ -65,13 +70,13 @@ type PanelSpec struct {
// ══════════════════════════════════════════════
type Query struct {
Kind qb.RequestType `json:"kind"`
Spec QuerySpec `json:"spec"`
Kind qb.RequestType `json:"kind" required:"true"`
Spec QuerySpec `json:"spec" required:"true"`
}
type QuerySpec struct {
Name string `json:"name,omitempty"`
Plugin QueryPlugin `json:"plugin"`
Plugin QueryPlugin `json:"plugin" required:"true"`
}
// ══════════════════════════════════════════════
@@ -82,8 +87,8 @@ type QuerySpec struct {
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind"`
Spec any `json:"spec"`
Kind variable.Kind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -138,7 +143,7 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
Display Display `json:"display" required:"true"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
@@ -158,8 +163,8 @@ type ListVariableSpec struct {
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
// leaf imports.
type Layout struct {
Kind dashboard.LayoutKind `json:"kind"`
Spec any `json:"spec"`
Kind dashboard.LayoutKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// layoutSpecs is the layout sum type factory. Perses only defines

View File

@@ -46,9 +46,9 @@ type Store interface {
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
PinForUser(ctx context.Context, preference *UserDashboardPreference) error
UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error
UnpinForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, dashboardID valuer.UUID) error
DeletePreferencesForDashboard(ctx context.Context, dashboardID valuer.UUID) error
DeletePreferencesForDashboard(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, userID valuer.UUID) error
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
}

View File

@@ -1,7 +1,10 @@
package dashboardtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -14,15 +17,20 @@ var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_
type UserDashboardPreference struct {
bun.BaseModel `bun:"table:user_dashboard_preference,alias:user_dashboard_preference"`
UserID valuer.UUID `bun:"user_id,pk,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,pk,type:text"`
types.Identifiable
types.TimeAuditable
UserID valuer.UUID `bun:"user_id,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,type:text"`
IsPinned bool `bun:"is_pinned,notnull,default:false"`
}
func NewUserDashboardPreference(userID, dashboardID valuer.UUID) *UserDashboardPreference {
now := time.Now()
return &UserDashboardPreference{
UserID: userID,
DashboardID: dashboardID,
IsPinned: true,
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserID: userID,
DashboardID: dashboardID,
IsPinned: true,
}
}

View File

@@ -1,8 +1,6 @@
package spantypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -39,17 +37,11 @@ type GettableFlamegraphTrace struct {
HasMore bool `json:"hasMore" required:"true"`
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, start, end time.Time, hasMore bool) *GettableFlamegraphTrace {
// convert timestamp to millisecond since client expect that
for _, level := range spans {
for _, span := range level {
span.Timestamp /= 1_000_000
}
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
return &GettableFlamegraphTrace{
Spans: spans,
StartTimestampMillis: start.UnixMilli(),
EndTimestampMillis: end.UnixMilli(),
StartTimestampMillis: startMs,
EndTimestampMillis: endMs,
HasMore: hasMore,
}
}

View File

@@ -1,5 +1,5 @@
import uuid
from collections.abc import Callable, Iterator
from collections.abc import Callable
from http import HTTPStatus
import pytest
@@ -8,96 +8,7 @@ import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
# The v2 dashboard API. Request shape (current):
# {"schemaVersion": "v6", "name": "<dns-1123-label>",
# "spec": {"display": {"name": "<human name>"}},
# "tags": [{"key": "...", "value": "..."}]}
# `name` is a DNS-1123 label identifier and is immutable after create;
# `spec.display.name` is the human-facing title used for name-sort/name-filter.
_BASE = "/api/v2/dashboards"
_TIMEOUT = 5
# This file's tests tag their dashboards with a `suite` marker so list queries
# can be scoped server-side. Each test gets its own unique marker (the
# suite_marker fixture) so tests stay isolated from each other and from leftovers
# in the reused session DB.
_SUITE_PREFIX = "dashboardv2"
def _headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _url(signoz: SigNoz, path: str = "") -> str:
return signoz.self.host_configs["8080"].get(f"{_BASE}{path}")
def _create(signoz: SigNoz, token: str, body: dict) -> requests.Response:
return requests.post(_url(signoz), json=body, headers=_headers(token), timeout=_TIMEOUT)
def _get(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
return requests.get(_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT)
# The tests exercise the per-user list (carries pin state); the pure list lives
# at GET /api/v2/dashboards.
def _list(signoz: SigNoz, token: str, **params: object) -> requests.Response:
url = signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards")
return requests.get(
url,
params={k: v for k, v in params.items() if v is not None},
headers=_headers(token),
timeout=_TIMEOUT,
)
# The pure, user-independent list — no pin join, no pinned field.
def _list_pure(signoz: SigNoz, token: str, **params: object) -> requests.Response:
return requests.get(
_url(signoz),
params={k: v for k, v in params.items() if v is not None},
headers=_headers(token),
timeout=_TIMEOUT,
)
def _update(signoz: SigNoz, token: str, dashboard_id: str, body: dict) -> requests.Response:
return requests.put(
_url(signoz, f"/{dashboard_id}"),
json=body,
headers=_headers(token),
timeout=_TIMEOUT,
)
def _delete(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
return requests.delete(_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT)
def _lock(signoz: SigNoz, token: str, dashboard_id: str, lock: bool) -> requests.Response:
method = requests.put if lock else requests.delete
return method(
_url(signoz, f"/{dashboard_id}/lock"),
headers=_headers(token),
timeout=_TIMEOUT,
)
def _pin(signoz: SigNoz, token: str, dashboard_id: str, pin: bool) -> requests.Response:
method = requests.put if pin else requests.delete
url = signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{dashboard_id}/pins")
return method(url, headers=_headers(token), timeout=_TIMEOUT)
def _minimal_body(name: str, display: str, tags: list[dict] | None = None) -> dict:
return {
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": tags or [],
}
BASE_URL = "/api/v2/dashboards"
# ─── failure cases (create no dashboards) ────────────────────────────────────
@@ -110,7 +21,12 @@ def test_create_rejects_wrong_schema_version(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, {})
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
@@ -126,7 +42,12 @@ def test_create_rejects_missing_name(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, {"schemaVersion": "v6"})
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
@@ -141,7 +62,17 @@ def test_create_rejects_non_dns_name(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, _minimal_body(name="Not A Label", display="Not A Label"))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "Not A Label",
"spec": {"display": {"name": "Not A Label"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -154,9 +85,18 @@ def test_create_rejects_unknown_field(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _minimal_body("rejects-unknown", "Rejects Unknown")
body["unknownfield"] = "boom"
response = _create(signoz, token, body)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}},
"tags": [],
"unknownfield": "boom",
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -170,8 +110,17 @@ def test_create_rejects_reserved_tag_key(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _minimal_body("rejects-reserved", "Rejects Reserved", [{"key": "source", "value": "x"}])
response = _create(signoz, token, body)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "rejects-reserved",
"spec": {"display": {"name": "Rejects Reserved"}},
"tags": [{"key": "source", "value": "x"}],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -185,7 +134,17 @@ def test_create_rejects_too_many_tags(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
tags = [{"key": f"k{i}", "value": "v"} for i in range(11)]
response = _create(signoz, token, _minimal_body("too-many-tags", "Too Many", tags))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "too-many-tags",
"spec": {"display": {"name": "Too Many"}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@@ -208,7 +167,12 @@ def test_list_rejects_invalid_params(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _list(signoz, token, **params)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_list_invalid"
@@ -221,7 +185,11 @@ def test_get_rejects_malformed_id(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _get(signoz, token, "not-a-uuid")
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@@ -233,7 +201,11 @@ def test_get_missing_dashboard_returns_not_found(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _get(signoz, token, str(uuid.uuid4()))
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@@ -245,7 +217,11 @@ def test_delete_missing_dashboard_returns_not_found(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _delete(signoz, token, str(uuid.uuid4()))
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@@ -257,58 +233,44 @@ def test_pin_missing_dashboard_returns_not_found(
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _pin(signoz, token, str(uuid.uuid4()), pin=True)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{uuid.uuid4()}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# ─── lifecycle ───────────────────────────────────────────────────────────────
# A single end-to-end flow through create → get → list/filter/sort → pin →
# update → lock → delete. Every fixture dashboard carries the shared suite marker
# tag so list queries can be scoped server-side, isolating this test from any
# other dashboards sharing the session DB.
def _display_names(body: dict) -> list[str]:
return [d["spec"]["display"]["name"] for d in body["data"]["dashboards"]]
def _delete_suite(signoz: SigNoz, token: str, suite_filter: str) -> None:
response = _list(signoz, token, query=suite_filter, limit=200)
if response.status_code != HTTPStatus.OK:
return
for dashboard in response.json()["data"]["dashboards"]:
_delete(signoz, token, dashboard["id"])
@pytest.fixture(name="suite_marker")
def _suite_marker(
signoz: SigNoz,
get_token: Callable[[str, str], str],
) -> Iterator[tuple[dict, str]]:
"""Yields a per-test unique suite (tag, filter) and deletes its dashboards on teardown.
Unique per test so the tests stay isolated from each other and from reused-DB leftovers."""
value = f"{_SUITE_PREFIX}-{uuid.uuid4().hex[:8]}"
suite_tag = {"key": "suite", "value": value}
suite_filter = f"suite = '{value}'"
yield suite_tag, suite_filter
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
_delete_suite(signoz, token, suite_filter)
# update → lock → delete.
def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-statements
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
suite_marker: tuple[dict, str],
):
suite_tag, suite_filter = suite_marker
def _scoped(query: str) -> str:
return f"({query}) AND {suite_filter}"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# The dashboard test files share this package's DB and it's reused across
# runs, so start from a clean slate: delete every dashboard (which also clears
# pins via the delete cascade). This test then owns the whole dashboard space
# and asserts on global counts.
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboard_requests = [
(
"lc-alpha",
@@ -353,18 +315,38 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
# ── stage 1: create ──────────────────────────────────────────────────────
ids: dict[str, str] = {}
for name, display, tags in dashboard_requests:
response = _create(signoz, token, _minimal_body(name, display, [suite_tag, *tags]))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
ids[name] = response.json()["data"]["id"]
# TODO: re-enable once the dashboard name unique index lands — creating a
# second dashboard with an existing name should conflict (409). Until the
# index exists, duplicate names are silently allowed.
# response = _create(signoz, token, _minimal_body("lc-alpha", "Alpha Dupe"))
# response = requests.post(
# signoz.self.host_configs["8080"].get(_BASE),
# json={"schemaVersion": "v6", "name": "lc-alpha",
# "spec": {"display": {"name": "Alpha Dupe"}}, "tags": []},
# headers={"Authorization": f"Bearer {token}"},
# timeout=5,
# )
# assert response.status_code == HTTPStatus.CONFLICT, response.text
# ── stage 2: get one and verify the round-tripped shape ──────────────────
response = _get(signoz, token, ids["lc-alpha"])
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
alpha = response.json()["data"]
assert alpha["id"] == ids["lc-alpha"]
@@ -375,12 +357,17 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert alpha["locked"] is False
assert {"key": "team", "value": "pulse"} in alpha["tags"]
# ── stage 3: list everything in the suite ────────────────────────────────
response = _list(signoz, token, query=suite_filter, limit=200)
# ── stage 3: list everything ─────────────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["data"]["total"] == 6
assert set(_display_names(body)) == {
assert {d["spec"]["display"]["name"] for d in body["data"]["dashboards"]} == {
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
@@ -490,13 +477,23 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
),
]
for query, expected in cases:
response = _list(signoz, token, query=_scoped(query), limit=200)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert set(_display_names(response.json())) == expected, query
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 5: name sort honours order ─────────────────────────────────────
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert _display_names(response.json()) == [
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -504,8 +501,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Gamma Storage",
"Zeta Overview",
]
response = _list(signoz, token, query=suite_filter, sort="name", order="desc", limit=200)
assert _display_names(response.json()) == [
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "desc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Zeta Overview",
"Gamma Storage",
"Epsilon Metrics",
@@ -515,8 +517,20 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
assert _pin(signoz, token, ids["lc-gamma"], pin=True).status_code == HTTPStatus.NO_CONTENT
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboards = response.json()["data"]["dashboards"]
assert dashboards[0]["name"] == "lc-gamma"
assert dashboards[0]["pinned"] is True
@@ -524,8 +538,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
# the pure list is user-independent: the same pin neither reorders it (gamma
# stays in natural name order, not floated to the top) nor adds a pinned field.
response = _list_pure(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert _display_names(response.json()) == [
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -536,9 +555,21 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert all("pinned" not in d for d in response.json()["data"]["dashboards"])
# ── stage 7: unpinning restores the natural ordering ─────────────────────
assert _pin(signoz, token, ids["lc-gamma"], pin=False).status_code == HTTPStatus.NO_CONTENT
response = _list(signoz, token, query=suite_filter, sort="name", order="asc", limit=200)
assert _display_names(response.json()) == [
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert [d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]] == [
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -548,39 +579,95 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
# ── stage 8: update mutates the spec but keeps the immutable name ────────
update_body = _minimal_body(
"lc-alpha",
"Alpha Overview",
[
suite_tag,
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
],
)
}
update_body["spec"]["display"]["description"] = "now with a description"
response = _update(signoz, token, ids["lc-alpha"], update_body)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
json=update_body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = _get(signoz, token, ids["lc-alpha"])
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.json()["data"]["spec"]["display"]["description"] == "now with a description"
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
assert _lock(signoz, token, ids["lc-beta"], lock=True).status_code == HTTPStatus.NO_CONTENT
beta_body = _minimal_body(
"lc-beta",
"Beta Overview",
[suite_tag, {"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}"),
json=beta_body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
response = _update(signoz, token, ids["lc-beta"], beta_body)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert _lock(signoz, token, ids["lc-beta"], lock=False).status_code == HTTPStatus.NO_CONTENT
assert _update(signoz, token, ids["lc-beta"], beta_body).status_code == HTTPStatus.OK
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}"),
json=beta_body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.OK
)
# ── stage 10: delete removes the dashboard from get and list ─────────────
assert _delete(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NO_CONTENT
assert _get(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NOT_FOUND
response = _list(signoz, token, query=suite_filter, limit=200)
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
assert (
requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NOT_FOUND
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.json()["data"]["total"] == 5
assert set(_display_names(response.json())) == {
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {
"Alpha Overview",
"Beta Overview",
"Delta Storage",
@@ -593,34 +680,89 @@ def test_dashboard_v2_pin_limit(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
suite_marker: tuple[dict, str],
):
suite_tag, _ = suite_marker
max_pinned = 10
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Wipe the dashboard space (see lifecycle) so the per-user pin cap this test
# asserts against starts empty — deleting dashboards clears their pins.
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
ids: list[str] = []
for i in range(max_pinned + 1):
response = _create(signoz, token, _minimal_body(f"pl-{i}", f"Pin Limit {i}", [suite_tag]))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
ids.append(response.json()["data"]["id"])
# pinning up to the limit succeeds
for dashboard_id in ids[:max_pinned]:
assert _pin(signoz, token, dashboard_id, pin=True).status_code == HTTPStatus.NO_CONTENT
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{dashboard_id}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
# re-pinning an already-pinned dashboard is an idempotent no-op, even at the limit
assert _pin(signoz, token, ids[0], pin=True).status_code == HTTPStatus.NO_CONTENT
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[0]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
# the 11th distinct pin is rejected with the typed limit error
response = _pin(signoz, token, ids[max_pinned], pin=True)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[max_pinned]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
assert response.json()["error"]["code"] == "pinned_dashboard_limit_hit"
# unpinning frees a slot, so the previously-rejected dashboard can now be pinned
assert _pin(signoz, token, ids[0], pin=False).status_code == HTTPStatus.NO_CONTENT
assert _pin(signoz, token, ids[max_pinned], pin=True).status_code == HTTPStatus.NO_CONTENT
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[0]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids[max_pinned]}/pins"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
# ─── LIKE escaping ───────────────────────────────────────────────────────────
@@ -638,12 +780,24 @@ def test_dashboard_v2_like_escaping(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
suite_marker: tuple[dict, str],
):
suite_tag, suite_filter = suite_marker
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Wipe the dashboard space (see lifecycle) so the filter assertions run
# against only the dashboards this test creates.
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboard_requests = [
("esc-pct", "Cost 50% Report"),
("esc-pct-plain", "Cost 5000 Report"),
@@ -651,7 +805,17 @@ def test_dashboard_v2_like_escaping(
("esc-underscore-wild", "userXid panel"),
]
for name, display in dashboard_requests:
response = _create(signoz, token, _minimal_body(name, display, [suite_tag]))
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
cases = [
@@ -685,11 +849,11 @@ def test_dashboard_v2_like_escaping(
),
]
for query, expected in cases:
response = _list(
signoz,
token,
query=f"({query}) AND {suite_filter}",
limit=200,
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert set(_display_names(response.json())) == expected, query
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query