mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 08:30:35 +01:00
Compare commits
34 Commits
nv/schema-
...
feat/llm-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1e01d3e6 | ||
|
|
a358fc717b | ||
|
|
feb6edc057 | ||
|
|
c9afc7a375 | ||
|
|
074915e22b | ||
|
|
eaef369eb1 | ||
|
|
a13969c8bc | ||
|
|
3f0d07c28c | ||
|
|
f2b3c4125c | ||
|
|
96cc6ec588 | ||
|
|
c6705a72a7 | ||
|
|
e1cb822091 | ||
|
|
44d76c1306 | ||
|
|
b9ac79898e | ||
|
|
c136834ab5 | ||
|
|
dd0155065f | ||
|
|
38f1506472 | ||
|
|
7692f99f30 | ||
|
|
c3ba316ccf | ||
|
|
eeb8353a17 | ||
|
|
47b5fff3f6 | ||
|
|
a0940af68f | ||
|
|
72713d996d | ||
|
|
93a1caa9cc | ||
|
|
bb8900113a | ||
|
|
1fb195b383 | ||
|
|
c2761c24df | ||
|
|
a6b77993c6 | ||
|
|
59d2468fb1 | ||
|
|
44d8d55b2f | ||
|
|
8fdce2b9ad | ||
|
|
1c06a57361 | ||
|
|
dfd3bce670 | ||
|
|
9725a268cc |
@@ -2437,6 +2437,17 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/VariableDisplay'
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -2801,15 +2812,9 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesLegendMode'
|
||||
position:
|
||||
$ref: '#/components/schemas/DashboardtypesLegendPosition'
|
||||
type: object
|
||||
DashboardtypesLegendMode:
|
||||
enum:
|
||||
- list
|
||||
type: string
|
||||
DashboardtypesLegendPosition:
|
||||
enum:
|
||||
- bottom
|
||||
@@ -2860,25 +2865,15 @@ components:
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
|
||||
sort:
|
||||
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- display
|
||||
type: object
|
||||
DashboardtypesListVariableSpecSort:
|
||||
enum:
|
||||
- none
|
||||
- alphabetical-asc
|
||||
- alphabetical-desc
|
||||
- numerical-asc
|
||||
- numerical-desc
|
||||
- alphabetical-ci-asc
|
||||
- alphabetical-ci-desc
|
||||
type: string
|
||||
DashboardtypesListableDashboardForUserV2:
|
||||
properties:
|
||||
dashboards:
|
||||
@@ -3388,13 +3383,8 @@ components:
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
description: The maximum gap size to connect when fillOnlyBelow is true.
|
||||
Gaps larger than this duration are left disconnected.
|
||||
type: string
|
||||
fillOnlyBelow:
|
||||
description: Controls whether lines connect across null values. When false
|
||||
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
|
||||
are connected.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesStorableDashboardData:
|
||||
@@ -3442,20 +3432,6 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -3475,6 +3451,7 @@ components:
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
- label
|
||||
type: object
|
||||
DashboardtypesTimePreference:
|
||||
enum:
|
||||
@@ -3559,11 +3536,23 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
|
||||
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
|
||||
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- TextVariable
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardTextVariableSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
|
||||
properties:
|
||||
@@ -3577,18 +3566,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- TextVariable
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariablePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7674,6 +7651,15 @@ components:
|
||||
type: object
|
||||
VariableDefaultValue:
|
||||
type: object
|
||||
VariableDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
ZeustypesGettableHost:
|
||||
properties:
|
||||
hosts:
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.14",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.0.0",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
@@ -231,16 +231,17 @@
|
||||
"xml2js": "0.5.0",
|
||||
"phin": "^3.7.1",
|
||||
"body-parser": "1.20.3",
|
||||
"http-proxy-middleware": "4.0.0",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"cross-spawn": "7.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"serialize-javascript": "6.0.2",
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.4",
|
||||
"form-data": "4.0.6",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4",
|
||||
"js-cookie": "^3.0.7",
|
||||
"tmp": "0.2.7",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
85
frontend/pnpm-lock.yaml
generated
85
frontend/pnpm-lock.yaml
generated
@@ -12,16 +12,17 @@ overrides:
|
||||
xml2js: 0.5.0
|
||||
phin: ^3.7.1
|
||||
body-parser: 1.20.3
|
||||
http-proxy-middleware: 4.0.0
|
||||
http-proxy-middleware: 4.1.1
|
||||
cross-spawn: 7.0.5
|
||||
cookie: ^0.7.1
|
||||
serialize-javascript: 6.0.2
|
||||
prismjs: 1.30.0
|
||||
got: 11.8.5
|
||||
form-data: 4.0.4
|
||||
form-data: 4.0.6
|
||||
brace-expansion: ^2.0.2
|
||||
on-headers: ^1.1.0
|
||||
tmp: 0.2.4
|
||||
js-cookie: ^3.0.7
|
||||
tmp: 0.2.7
|
||||
vite: npm:rolldown-vite@7.3.1
|
||||
|
||||
importers:
|
||||
@@ -56,8 +57,8 @@ importers:
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2(react@18.2.0)
|
||||
'@grafana/data':
|
||||
specifier: ^11.6.14
|
||||
version: 11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
specifier: ^11.6.15
|
||||
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -164,8 +165,8 @@ importers:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
http-proxy-middleware:
|
||||
specifier: 4.0.0
|
||||
version: 4.0.0
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -1636,14 +1637,14 @@ packages:
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
|
||||
'@grafana/data@11.6.14':
|
||||
resolution: {integrity: sha512-Nsjq1A9m6LbsKsKvOgvAk9Wq7RGjy0V4N9d5YsSnzMwCiw/ov2wblR2bcDpy95uF8KaDTIR2Gf40nJaOYksPMA==}
|
||||
'@grafana/data@11.6.15':
|
||||
resolution: {integrity: sha512-q2Zbjr0N9iEGY/zKHm4Z4X5x64806E17W58y7mnvwc0MlbyGPPVulcp/rWA2Nd190mZeafZQPer9u+MaO+0HUQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
|
||||
'@grafana/schema@11.6.14':
|
||||
resolution: {integrity: sha512-YTqgYekb7kiu5NEoQxKF8czJ6QIARmMkCi9cNcynHqYpcDLOv5pg5Q0QtKgiiqHjlYoEeCV6iejdB4hXxzB+VA==}
|
||||
'@grafana/schema@11.6.15':
|
||||
resolution: {integrity: sha512-MPIvGAp9uzkswnH6e+Fmzu+WBTqWMgbv93/8iu56gb+sjCB2LciZLz4KvrPFdw32bWCGSMAGqsML9mgmeJZtGQ==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
@@ -5167,8 +5168,8 @@ packages:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.4:
|
||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
format@0.2.2:
|
||||
@@ -5381,6 +5382,10 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-from-parse5@8.0.1:
|
||||
resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==}
|
||||
|
||||
@@ -5456,8 +5461,8 @@ packages:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-middleware@4.0.0:
|
||||
resolution: {integrity: sha512-wuHwaUtmC0XzJNHqRp41zXtt5ojpHbusXGhq6781VvnjWUYPu7opmOF3eomGNujT07kEOnHWZyV9UZzKimVCKA==}
|
||||
http-proxy-middleware@4.1.1:
|
||||
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
@@ -5467,8 +5472,8 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpxy@0.5.1:
|
||||
resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==}
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
@@ -6041,8 +6046,8 @@ packages:
|
||||
js-base64@3.7.5:
|
||||
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
|
||||
|
||||
js-cookie@2.2.1:
|
||||
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
||||
js-cookie@3.0.8:
|
||||
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
|
||||
|
||||
js-levenshtein@1.1.6:
|
||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||
@@ -8394,8 +8399,8 @@ packages:
|
||||
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
tmp@0.2.4:
|
||||
resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==}
|
||||
tmp@0.2.7:
|
||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
tmpl@1.0.5:
|
||||
@@ -10318,10 +10323,10 @@ snapshots:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@grafana/data@11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
'@grafana/data@11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.0.1
|
||||
'@grafana/schema': 11.6.14
|
||||
'@grafana/schema': 11.6.15
|
||||
'@types/d3-interpolate': 3.0.1
|
||||
'@types/string-hash': 1.1.3
|
||||
d3-interpolate: 3.0.1
|
||||
@@ -10347,7 +10352,7 @@ snapshots:
|
||||
uplot: 1.6.31
|
||||
xss: 1.0.14
|
||||
|
||||
'@grafana/schema@11.6.14':
|
||||
'@grafana/schema@11.6.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -12886,7 +12891,7 @@ snapshots:
|
||||
axios@1.16.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.4
|
||||
form-data: 4.0.6
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -13833,7 +13838,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
|
||||
es-toolkit@1.46.1: {}
|
||||
|
||||
@@ -14031,7 +14036,7 @@ snapshots:
|
||||
dependencies:
|
||||
chardet: 0.7.0
|
||||
iconv-lite: 0.4.24
|
||||
tmp: 0.2.4
|
||||
tmp: 0.2.7
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
@@ -14164,12 +14169,12 @@ snapshots:
|
||||
cross-spawn: 7.0.5
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data@4.0.4:
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
format@0.2.2: {}
|
||||
@@ -14248,7 +14253,7 @@ snapshots:
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
@@ -14386,6 +14391,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-from-parse5@8.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -14506,10 +14515,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy-middleware@4.0.0:
|
||||
http-proxy-middleware@4.1.1:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@5.5.0)
|
||||
httpxy: 0.5.1
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
@@ -14525,7 +14534,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.1: {}
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
@@ -15339,7 +15348,7 @@ snapshots:
|
||||
|
||||
js-base64@3.7.5: {}
|
||||
|
||||
js-cookie@2.2.1: {}
|
||||
js-cookie@3.0.8: {}
|
||||
|
||||
js-levenshtein@1.1.6: {}
|
||||
|
||||
@@ -15367,7 +15376,7 @@ snapshots:
|
||||
decimal.js: 10.6.0
|
||||
domexception: 4.0.0
|
||||
escodegen: 2.1.0
|
||||
form-data: 4.0.4
|
||||
form-data: 4.0.6
|
||||
html-encoding-sniffer: 3.0.0
|
||||
http-proxy-agent: 5.0.0
|
||||
https-proxy-agent: 5.0.1
|
||||
@@ -17336,7 +17345,7 @@ snapshots:
|
||||
copy-to-clipboard: 3.3.3
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-shallow-equal: 1.0.0
|
||||
js-cookie: 2.2.1
|
||||
js-cookie: 3.0.8
|
||||
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -17355,7 +17364,7 @@ snapshots:
|
||||
copy-to-clipboard: 3.3.3
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-shallow-equal: 1.0.0
|
||||
js-cookie: 2.2.1
|
||||
js-cookie: 3.0.8
|
||||
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
@@ -18103,7 +18112,7 @@ snapshots:
|
||||
|
||||
tinypool@2.1.0: {}
|
||||
|
||||
tmp@0.2.4: {}
|
||||
tmp@0.2.7: {}
|
||||
|
||||
tmpl@1.0.5: {}
|
||||
|
||||
|
||||
@@ -330,3 +330,10 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityAttributeMappingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityAttributeMappingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -505,6 +506,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityAttributeMappingPage,
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -3154,6 +3154,37 @@ export interface DashboardLinkDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface VariableDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DashboardTextVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
constant?: boolean;
|
||||
display?: VariableDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -3185,9 +3216,6 @@ export interface DashboardtypesPanelFormattingDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
@@ -3207,7 +3235,6 @@ export interface DashboardtypesLegendDTO {
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
@@ -3219,7 +3246,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label?: string;
|
||||
label: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3884,12 +3911,10 @@ export enum DashboardtypesLineStyleDTO {
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
@@ -4521,15 +4546,6 @@ export type DashboardtypesVariablePluginDTO =
|
||||
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
|
||||
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
|
||||
|
||||
export enum DashboardtypesListVariableSpecSortDTO {
|
||||
none = 'none',
|
||||
'alphabetical-asc' = 'alphabetical-asc',
|
||||
'alphabetical-desc' = 'alphabetical-desc',
|
||||
'numerical-asc' = 'numerical-asc',
|
||||
'numerical-desc' = 'numerical-desc',
|
||||
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
|
||||
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
|
||||
}
|
||||
export interface DashboardtypesListVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4548,14 +4564,16 @@ export interface DashboardtypesListVariableSpecDTO {
|
||||
*/
|
||||
customAllValue?: string;
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @minLength 1
|
||||
*/
|
||||
name: string;
|
||||
name?: string;
|
||||
plugin?: DashboardtypesVariablePluginDTO;
|
||||
sort?: DashboardtypesListVariableSpecSortDTO;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
sort?: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
|
||||
@@ -4567,38 +4585,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
|
||||
spec: DashboardtypesListVariableSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
|
||||
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
|
||||
TextVariable = 'TextVariable',
|
||||
}
|
||||
export interface DashboardtypesTextVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
constant?: boolean;
|
||||
display?: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @minLength 1
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
|
||||
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
|
||||
/**
|
||||
* @enum TextVariable
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
|
||||
spec: DashboardtypesTextVariableSpecDTO;
|
||||
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
|
||||
spec: DashboardTextVariableSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesVariableDTO =
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
|
||||
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
@@ -8134,6 +8135,44 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -8429,6 +8468,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -9797,6 +9863,14 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
|
||||
@@ -30,8 +30,10 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -91,6 +91,8 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
|
||||
'/llm-observability/settings/attribute-mapping',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function AttributeMappingHeader({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>Attribute Mapping</h1>
|
||||
<p className={styles.description}>
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.pageHeaderActions}>
|
||||
{isDirty && (
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingHeader;
|
||||
@@ -0,0 +1,41 @@
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import MapperGroupsTable from './MapperGroupsTable';
|
||||
import { DraftGroup } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
|
||||
interface AttributeMappingsTabProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// "Attribute mappings" tab: the mapping-groups listing, its error state and
|
||||
// footer summary. The store is owned by the container (the header's save/
|
||||
// discard share it), so it's passed in rather than created here.
|
||||
function AttributeMappingsTab({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: AttributeMappingsTabProps): JSX.Element {
|
||||
return (
|
||||
<div data-testid="attribute-mappings-tab">
|
||||
{store.isError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
Failed to load mapping groups. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapperGroupsTable
|
||||
store={store}
|
||||
onEditGroup={onEditGroup}
|
||||
onAddGroup={onAddGroup}
|
||||
/>
|
||||
|
||||
<footer className={styles.pageFooter}>
|
||||
Showing {store.groups.length} group{store.groups.length === 1 ? '' : 's'}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingsTab;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import { FieldContextValue } from './types';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: string[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
// Editor for one list of condition keys (the group's span-attribute or
|
||||
// resource gating keys). Substring "contains" match, order irrelevant.
|
||||
function ConditionKeyList({
|
||||
label,
|
||||
labelHint,
|
||||
keys,
|
||||
placeholder,
|
||||
addLabel,
|
||||
testIdPrefix,
|
||||
fieldContext,
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, '']);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
onChange(keys.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>
|
||||
{label}
|
||||
{labelHint && (
|
||||
<span className={styles.groupFormLabelHint}> {labelHint}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{keys.length > 0 && (
|
||||
<div className={styles.groupFormKeys}>
|
||||
{keys.map((key, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.groupFormKey} key={index}>
|
||||
<KeySearchInput
|
||||
className={styles.groupFormKeyInput}
|
||||
placeholder={placeholder}
|
||||
value={key}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove key"
|
||||
onClick={(): void => removeKey(index)}
|
||||
testId={`${testIdPrefix}-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addKey}
|
||||
testId={`${testIdPrefix}-add`}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionKeyList;
|
||||
@@ -0,0 +1,76 @@
|
||||
.groupForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.groupFormField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFieldRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.groupFormLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormLabelHint {
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.groupFormHint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.groupFormKeys {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormKey {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.groupFormKeyInput {
|
||||
flex: 1;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.groupFormError {
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.groupFormFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupFormFooterActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
|
||||
import ConditionKeyList from './ConditionKeyList';
|
||||
import styles from './GroupFormDrawer.module.scss';
|
||||
import { FieldContext, GroupDraft, MapperDraftMode } from './types';
|
||||
import { isGroupDraftValid } from './utils';
|
||||
|
||||
interface GroupFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function GroupFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: GroupFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isGroupDraftValid(draft);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit group' : 'New group'}
|
||||
subTitle="A group gates which spans its mappings run on"
|
||||
width="wide"
|
||||
testId="group-form-drawer"
|
||||
footer={
|
||||
<div className={styles.groupFormFooter}>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="group-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.groupFormFooterActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="group-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="group-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.groupForm}>
|
||||
<div className={styles.groupFormField}>
|
||||
<span className={styles.groupFormLabel}>Group name</span>
|
||||
<Input
|
||||
placeholder="e.g. OpenAI gateway"
|
||||
value={draft.name}
|
||||
onChange={(event): void =>
|
||||
setDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
testId="group-form-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
|
||||
<span className={styles.groupFormLabel}>Enabled</span>
|
||||
<Switch
|
||||
value={draft.enabled}
|
||||
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
|
||||
testId="group-form-enabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · span attribute keys"
|
||||
labelHint="· runs when a span attribute key contains any of these"
|
||||
keys={draft.attributes}
|
||||
placeholder="e.g. gen_ai."
|
||||
addLabel="Add attribute key"
|
||||
testIdPrefix="group-form-attribute"
|
||||
fieldContext={FieldContext.attribute}
|
||||
onChange={(attributes): void => setDraft({ ...draft, attributes })}
|
||||
/>
|
||||
|
||||
<ConditionKeyList
|
||||
label="Condition · resource keys"
|
||||
labelHint="· or when a resource key contains any of these"
|
||||
keys={draft.resource}
|
||||
placeholder="e.g. service.name"
|
||||
addLabel="Add resource key"
|
||||
testIdPrefix="group-form-resource"
|
||||
fieldContext={FieldContext.resource}
|
||||
onChange={(resource): void => setDraft({ ...draft, resource })}
|
||||
/>
|
||||
|
||||
<span className={styles.groupFormHint}>
|
||||
Leave both empty to run this group on every span.
|
||||
</span>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.groupFormError} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupFormDrawer;
|
||||
@@ -0,0 +1,51 @@
|
||||
.keySearch {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keySearchDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-2));
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
max-width: 420px;
|
||||
max-height: 240px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.keySearchDropdownEmpty {
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.keySearchOption {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import Spinner from 'components/Spinner';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
import styles from './KeySearchInput.module.scss';
|
||||
import { FieldContext, FieldContextValue } from './types';
|
||||
|
||||
const SUGGESTION_LIMIT = 50;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
interface KeySearchInputProps {
|
||||
value: string;
|
||||
fieldContext: FieldContextValue;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// Maps the mapper's attribute/resource context to the fields-endpoint context.
|
||||
function toFieldsContext(
|
||||
context: FieldContextValue,
|
||||
): TelemetrytypesFieldContextDTO {
|
||||
return context === FieldContext.resource
|
||||
? TelemetrytypesFieldContextDTO.resource
|
||||
: TelemetrytypesFieldContextDTO.attribute;
|
||||
}
|
||||
|
||||
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
|
||||
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
|
||||
function KeySearchInput({
|
||||
value,
|
||||
fieldContext,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
testId,
|
||||
onChange,
|
||||
}: KeySearchInputProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: toFieldsContext(fieldContext),
|
||||
searchText: debouncedSearch,
|
||||
limit: SUGGESTION_LIMIT,
|
||||
},
|
||||
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const keys = data?.data?.keys ?? {};
|
||||
return Object.keys(keys)
|
||||
.filter((key) => key !== value)
|
||||
.slice(0, SUGGESTION_LIMIT);
|
||||
}, [data, value]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.keySearch, className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
onChange={(event): void => {
|
||||
onChange(event.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onBlur={(): void => setIsOpen(false)}
|
||||
testId={testId}
|
||||
/>
|
||||
{isOpen && suggestions.length > 0 && (
|
||||
<div
|
||||
className={styles.keySearchDropdown}
|
||||
data-testid={`${testId}-dropdown`}
|
||||
>
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
className={styles.keySearchOption}
|
||||
// onMouseDown (not onClick) so selection runs before the input blur.
|
||||
onMouseDown={(event): void => {
|
||||
event.preventDefault();
|
||||
onChange(name);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
data-testid={`${testId}-option-${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && isFetching && suggestions.length === 0 && (
|
||||
<div
|
||||
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
|
||||
>
|
||||
<Spinner size="small" height="auto" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeySearchInput;
|
||||
@@ -0,0 +1,161 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.pageFooter {
|
||||
margin-top: var(--spacing-8);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.addRow {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.groupsTableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupsTableNameCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.groupsTableName {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.groupsTableFilters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
// Allow this column to shrink within the cell so long keys wrap
|
||||
// instead of forcing the cell wider than its allotted width.
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupsTableFilter {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
min-width: 0;
|
||||
|
||||
// Keep the context badge at full width; only the key text flexes.
|
||||
> *:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.groupsTableFilterKey {
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mappersTableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: var(--margin-1) 0;
|
||||
}
|
||||
|
||||
.mappersTableTarget {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.mappersTableSources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.mappersTableSourceChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
padding: 2px var(--padding-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l3-background);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mappersTableSourceMore {
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import AttributeMappingHeader from './AttributeMappingHeader';
|
||||
import AttributeMappingsTab from './AttributeMappingsTab';
|
||||
import GroupFormDrawer from './GroupFormDrawer';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import TestTab from './TestTab';
|
||||
import { useAttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useGroupFormDrawer } from './useGroupFormDrawer';
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
const store = useAttributeMappingStore();
|
||||
const groupDrawer = useGroupFormDrawer();
|
||||
|
||||
const handleGroupSave = useCallback((): void => {
|
||||
store.upsertGroup(groupDrawer.draft);
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const handleGroupDelete = useCallback((): void => {
|
||||
if (groupDrawer.draft.id) {
|
||||
store.removeGroup(groupDrawer.draft.id);
|
||||
}
|
||||
groupDrawer.close();
|
||||
}, [store, groupDrawer]);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'attribute-mappings',
|
||||
label: 'Attribute mappings',
|
||||
children: (
|
||||
<AttributeMappingsTab
|
||||
store={store}
|
||||
onEditGroup={groupDrawer.openForEdit}
|
||||
onAddGroup={groupDrawer.openForAdd}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: 'Test',
|
||||
children: <TestTab store={store} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
onDiscard={store.discard}
|
||||
onSave={store.save}
|
||||
/>
|
||||
|
||||
{store.saveError && (
|
||||
<div className={styles.pageError} role="alert">
|
||||
{store.saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
testId="attribute-mapping-tabs"
|
||||
defaultValue="attribute-mappings"
|
||||
items={tabItems}
|
||||
/>
|
||||
{groupDrawer.isOpen && (
|
||||
<GroupFormDrawer
|
||||
isOpen={groupDrawer.isOpen}
|
||||
mode={groupDrawer.mode}
|
||||
draft={groupDrawer.draft}
|
||||
setDraft={groupDrawer.setDraft}
|
||||
onClose={groupDrawer.close}
|
||||
onSave={handleGroupSave}
|
||||
onDelete={handleGroupDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -0,0 +1,104 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.labelHint {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.fieldContext {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sourceHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sourceIndex {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.sourceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.sourceSelect {
|
||||
flex: 0 0 auto;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import styles from './MapperFormDrawer.module.scss';
|
||||
import SourceAttributeRow from './SourceAttributeRow';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperDraft,
|
||||
MapperDraftMode,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
import { createEmptySource, isMapperDraftValid } from './utils';
|
||||
|
||||
const FIELD_CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Span attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
interface MapperFormDrawerProps {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
function MapperFormDrawer({
|
||||
isOpen,
|
||||
mode,
|
||||
draft,
|
||||
setDraft,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
isSaving,
|
||||
isDeleting,
|
||||
saveError,
|
||||
}: MapperFormDrawerProps): JSX.Element {
|
||||
const isEdit = mode === 'edit';
|
||||
const isValid = isMapperDraftValid(draft);
|
||||
|
||||
// Stable per-row ids for the sortable list. These are UI-only (never sent to
|
||||
// the API and excluded from the draft), so dnd-kit can track rows reliably
|
||||
// even though sources are stored as a plain array. Re-seeded each time the
|
||||
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
|
||||
const [rowIds, setRowIds] = useState<string[]>(() =>
|
||||
draft.sources.map(() => uuid()),
|
||||
);
|
||||
const sourceIds = draft.sources.map(
|
||||
(_, index) => rowIds[index] ?? `pending-${index}`,
|
||||
);
|
||||
|
||||
// 5px activation distance so clicking into the input never starts a drag.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
|
||||
const sources = draft.sources.map((source, i) =>
|
||||
i === index ? { ...source, ...patch } : source,
|
||||
);
|
||||
setDraft({ ...draft, sources });
|
||||
};
|
||||
|
||||
const addSource = (): void => {
|
||||
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
|
||||
setRowIds((prev) => [...prev, uuid()]);
|
||||
};
|
||||
|
||||
const removeSource = (index: number): void => {
|
||||
const sources = draft.sources.filter((_, i) => i !== index);
|
||||
if (sources.length === 0) {
|
||||
setDraft({ ...draft, sources: [createEmptySource()] });
|
||||
setRowIds([uuid()]);
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources });
|
||||
setRowIds((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = sourceIds.indexOf(String(active.id));
|
||||
const to = sourceIds.indexOf(String(over.id));
|
||||
if (from === -1 || to === -1) {
|
||||
return;
|
||||
}
|
||||
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
|
||||
setRowIds((prev) => arrayMove(prev, from, to));
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
|
||||
subTitle="Map source attributes onto a canonical target attribute"
|
||||
width="wide"
|
||||
testId="mapper-form-drawer"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
prefix={<Trash2 size={14} />}
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
testId="mapper-form-delete"
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.footerActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
testId="mapper-form-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
disabled={!isValid || isSaving}
|
||||
testId="mapper-form-save"
|
||||
>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Target attribute</span>
|
||||
<KeySearchInput
|
||||
placeholder="e.g. gen_ai.content.prompt"
|
||||
value={draft.name}
|
||||
fieldContext={draft.fieldContext}
|
||||
disabled={isEdit}
|
||||
onChange={(name): void => setDraft({ ...draft, name })}
|
||||
testId="mapper-form-target"
|
||||
/>
|
||||
{isEdit && (
|
||||
<span className={styles.hint}>
|
||||
The target attribute can't be changed after creation.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Write target to</span>
|
||||
<SelectSimple
|
||||
className={styles.fieldContext}
|
||||
items={FIELD_CONTEXT_OPTIONS}
|
||||
value={draft.fieldContext}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
setDraft({ ...draft, fieldContext: next as FieldContextValue })
|
||||
}
|
||||
testId="mapper-form-field-context"
|
||||
/>
|
||||
<span className={styles.hint}>
|
||||
Where the standardized attribute is written.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
Source attributes
|
||||
<span className={styles.labelHint}>
|
||||
{' '}
|
||||
· priority: top → bottom · drag to reorder
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
|
||||
<div className={styles.sources}>
|
||||
{draft.sources.map((source, index) => (
|
||||
<SourceAttributeRow
|
||||
key={sourceIds[index]}
|
||||
id={sourceIds[index]}
|
||||
index={index}
|
||||
value={source}
|
||||
canRemove={draft.sources.length > 1}
|
||||
onChange={updateSource}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={addSource}
|
||||
testId="mapper-form-add-source"
|
||||
>
|
||||
Add another source
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperFormDrawer;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import { getMapperGroupsColumns } from './mapperGroups.config';
|
||||
import MappersTable from './MappersTable';
|
||||
import { DraftGroup } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
|
||||
const SKELETON_ROW_COUNT = 5;
|
||||
|
||||
interface MapperGroupsTableProps {
|
||||
store: AttributeMappingStore;
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onAddGroup: () => void;
|
||||
}
|
||||
|
||||
// Top-level listing of mapping groups. Each row expands to reveal its mappers,
|
||||
// which MappersTable fetches lazily on first open. Built on the shared
|
||||
// TanStackTable with virtual scroll disabled — this is a small, content-height
|
||||
// list, and nested expanded tables need to grow with their content rather than
|
||||
// live inside a fixed-height viewport. Row actions (edit/delete/toggle) live in
|
||||
// the column config; the add-group affordance sits below the table.
|
||||
function MapperGroupsTable({
|
||||
store,
|
||||
onEditGroup,
|
||||
onAddGroup,
|
||||
}: MapperGroupsTableProps): JSX.Element {
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getMapperGroupsColumns({
|
||||
onEditGroup,
|
||||
onRemoveGroup: store.removeGroup,
|
||||
onToggleGroup: store.toggleGroup,
|
||||
}),
|
||||
[onEditGroup, store.removeGroup, store.toggleGroup],
|
||||
);
|
||||
|
||||
const isEmpty = !store.isLoading && store.groups.length === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.groupsTableWrapper}>
|
||||
{isEmpty ? (
|
||||
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
|
||||
No mapping groups yet.
|
||||
</div>
|
||||
) : (
|
||||
<TanStackTable<DraftGroup>
|
||||
data={store.groups}
|
||||
columns={columns}
|
||||
isLoading={store.isLoading}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.localId}
|
||||
getRowCanExpand={(): boolean => true}
|
||||
renderExpandedRow={(row): JSX.Element => (
|
||||
<MappersTable group={row} store={store} />
|
||||
)}
|
||||
disableVirtualScroll
|
||||
testId="mapper-groups-table"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
className={styles.addRow}
|
||||
onClick={onAddGroup}
|
||||
testId="add-group-row"
|
||||
disabled={store.isLoading}
|
||||
>
|
||||
Add a new group
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapperGroupsTable;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { useListSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import MapperFormDrawer from './MapperFormDrawer';
|
||||
import { getMappersColumns } from './mappers.config';
|
||||
import { DraftGroup, DraftMapper, Mapper } from './types';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useMapperFormDrawer } from './useMapperFormDrawer';
|
||||
|
||||
const SKELETON_ROW_COUNT = 3;
|
||||
|
||||
interface MappersTableProps {
|
||||
group: DraftGroup;
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
// Nested table of a group's mappers, rendered inside the group's expanded row.
|
||||
// This component only mounts when its group row is expanded, so the fetch is
|
||||
// lazy by construction — a group's mappers load on first open, are folded into
|
||||
// the store's draft so they're editable, and are then cached by react-query.
|
||||
// New (unsaved) groups have no serverId, so skip the fetch.
|
||||
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
|
||||
const drawer = useMapperFormDrawer();
|
||||
const { hydrateGroupMappers, upsertMapper, removeMapper, toggleMapper } = store;
|
||||
|
||||
const hasServerId = group.serverId !== null;
|
||||
const { data, isLoading, isError } = useListSpanMappers(
|
||||
{ groupId: group.serverId ?? '' },
|
||||
{ query: { enabled: hasServerId } },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const items = data?.data?.items;
|
||||
if (group.serverId && items) {
|
||||
// The generated schema mis-types this list response with the groups DTO;
|
||||
// the runtime payload is mappers.
|
||||
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
|
||||
}
|
||||
}, [group.serverId, data, hydrateGroupMappers]);
|
||||
|
||||
const isLoadingMappers = hasServerId && isLoading;
|
||||
const isErrorMappers = hasServerId && isError;
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
upsertMapper(group.localId, drawer.draft);
|
||||
drawer.close();
|
||||
}, [upsertMapper, group.localId, drawer]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
if (drawer.draft.id) {
|
||||
removeMapper(group.localId, drawer.draft.id);
|
||||
}
|
||||
drawer.close();
|
||||
}, [removeMapper, group.localId, drawer]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getMappersColumns({
|
||||
onEdit: drawer.openForEdit,
|
||||
onRemove: (localId): void => removeMapper(group.localId, localId),
|
||||
onToggle: (localId, enabled): void =>
|
||||
toggleMapper(group.localId, localId, enabled),
|
||||
}),
|
||||
[drawer.openForEdit, removeMapper, toggleMapper, group.localId],
|
||||
);
|
||||
|
||||
let content: JSX.Element;
|
||||
if (isErrorMappers) {
|
||||
content = (
|
||||
<div
|
||||
className={styles.tableEmpty}
|
||||
data-testid={`mappers-error-${group.localId}`}
|
||||
>
|
||||
Failed to load mappings. Please try again.
|
||||
</div>
|
||||
);
|
||||
} else if (!isLoadingMappers && group.mappers.length === 0) {
|
||||
content = (
|
||||
<div
|
||||
className={styles.tableEmpty}
|
||||
data-testid={`mappers-empty-${group.localId}`}
|
||||
>
|
||||
No mappings in this group yet.
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<TanStackTable<DraftMapper>
|
||||
data={group.mappers}
|
||||
columns={columns}
|
||||
isLoading={isLoadingMappers}
|
||||
skeletonRowCount={SKELETON_ROW_COUNT}
|
||||
getRowKey={(row): string => row.localId}
|
||||
disableVirtualScroll
|
||||
testId={`mappers-table-${group.localId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.mappersTableWrapper}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
className={styles.addRow}
|
||||
onClick={drawer.openForAdd}
|
||||
testId={`add-mapper-${group.localId}`}
|
||||
>
|
||||
Add mapping
|
||||
</Button>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<MapperFormDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
mode={drawer.mode}
|
||||
draft={drawer.draft}
|
||||
setDraft={drawer.setDraft}
|
||||
onClose={drawer.close}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
isSaving={false}
|
||||
isDeleting={false}
|
||||
saveError={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MappersTable;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, X } from '@signozhq/icons';
|
||||
|
||||
import KeySearchInput from './KeySearchInput';
|
||||
import styles from './MapperFormDrawer.module.scss';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldContextValue,
|
||||
MapperOperation,
|
||||
MapperOperationValue,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: FieldContext.attribute, label: 'Attribute' },
|
||||
{ value: FieldContext.resource, label: 'Resource' },
|
||||
];
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: MapperOperation.move, label: 'Move' },
|
||||
{ value: MapperOperation.copy, label: 'Copy' },
|
||||
];
|
||||
|
||||
interface SourceAttributeRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
value: SourceConfig;
|
||||
canRemove: boolean;
|
||||
onChange: (index: number, patch: Partial<SourceConfig>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
// A single draggable source row. Order = priority (top wins). Each source can
|
||||
// be read from a span attribute or the resource, and moved (delete source) or
|
||||
// copied (keep source). Only the grip is a drag handle.
|
||||
function SourceAttributeRow({
|
||||
id,
|
||||
index,
|
||||
value,
|
||||
canRemove,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: SourceAttributeRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.source} ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className={styles.sourceHandle}
|
||||
data-testid={`mapper-form-source-handle-${index}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<span className={styles.sourceIndex}>{index + 1}</span>
|
||||
<KeySearchInput
|
||||
className={styles.sourceInput}
|
||||
placeholder="Source attribute key"
|
||||
value={value.key}
|
||||
fieldContext={value.context}
|
||||
onChange={(key): void => onChange(index, { key })}
|
||||
testId={`mapper-form-source-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
items={CONTEXT_OPTIONS}
|
||||
value={value.context}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { context: next as FieldContextValue })
|
||||
}
|
||||
testId={`mapper-form-source-context-${index}`}
|
||||
/>
|
||||
<SelectSimple
|
||||
className={styles.sourceSelect}
|
||||
items={OPERATION_OPTIONS}
|
||||
value={value.operation}
|
||||
withPortal={false}
|
||||
onChange={(next): void =>
|
||||
onChange(index, { operation: next as MapperOperationValue })
|
||||
}
|
||||
testId={`mapper-form-source-operation-${index}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Remove source"
|
||||
disabled={!canRemove}
|
||||
onClick={(): void => onRemove(index)}
|
||||
testId={`mapper-form-source-remove-${index}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourceAttributeRow;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
|
||||
import styles from './TestTab.module.scss';
|
||||
|
||||
interface TestResultProps {
|
||||
index: number;
|
||||
span: SpantypesSpanMapperTestSpanDTO;
|
||||
inputAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Partial<
|
||||
Record<
|
||||
AttrChangeStatus,
|
||||
{ color: 'success' | 'robin' | 'sienna'; label: string }
|
||||
>
|
||||
> = {
|
||||
added: { color: 'success', label: 'populated' },
|
||||
changed: { color: 'robin', label: 'remapped' },
|
||||
removed: { color: 'sienna', label: 'moved out' },
|
||||
};
|
||||
|
||||
// Only added/removed rows carry a background treatment; the rest render plain.
|
||||
// Kept as an explicit map so we never do dynamic `styles[...]` access.
|
||||
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
|
||||
added: styles.added,
|
||||
removed: styles.removed,
|
||||
};
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Renders one transformed span as a key/value list, highlighting which target
|
||||
// attributes the mappers populated and which source keys a move consumed.
|
||||
function TestResult({
|
||||
index,
|
||||
span,
|
||||
inputAttributes,
|
||||
}: TestResultProps): JSX.Element {
|
||||
const entries = useMemo(
|
||||
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
|
||||
[inputAttributes, span.attributes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
|
||||
<div className={styles.resultTitle}>Resulting attributes</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>This span has no attributes.</div>
|
||||
) : (
|
||||
<div className={styles.attrRows}>
|
||||
{entries.map((entry) => {
|
||||
const badge = STATUS_BADGE[entry.status];
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
|
||||
>
|
||||
<span className={styles.attrKey} title={entry.key}>
|
||||
{entry.key}
|
||||
</span>
|
||||
<span className={styles.attrValue} title={formatValue(entry.value)}>
|
||||
{formatValue(entry.value)}
|
||||
</span>
|
||||
{badge ? (
|
||||
<Badge color={badge.color} variant="outline">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestResult;
|
||||
@@ -0,0 +1,129 @@
|
||||
.testTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.resultEmpty {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.attrRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.attrRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--padding-3);
|
||||
border-radius: var(--radius-2);
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&.added {
|
||||
background: var(--callout-success-background);
|
||||
}
|
||||
|
||||
&.removed {
|
||||
opacity: 0.65;
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attrKey,
|
||||
.attrValue {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attrKey {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.attrValue {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Play } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './TestTab.module.scss';
|
||||
import TestResult from './TestResult';
|
||||
import { AttributeMappingStore } from './useAttributeMappingStore';
|
||||
import { useTestSpanMapper } from './useTestSpanMapper';
|
||||
|
||||
interface TestTabProps {
|
||||
store: AttributeMappingStore;
|
||||
}
|
||||
|
||||
// "Test" tab: paste a sample span, run it through the working draft's mappers,
|
||||
// and see which target attributes get populated. Only groups whose mappers
|
||||
// changed are sent in full — unchanged groups are backfilled server-side.
|
||||
function TestTab({ store }: TestTabProps): JSX.Element {
|
||||
const { input, setInput, run, isRunning, result, testedAttributes, error } =
|
||||
useTestSpanMapper(store.snapshot, store.groups);
|
||||
|
||||
return (
|
||||
<div className={styles.testTab} data-testid="test-tab">
|
||||
<h3 className={styles.heading}>Test with sample span</h3>
|
||||
<p className={styles.description}>
|
||||
Paste a JSON span object to see which target attributes get populated and
|
||||
which source key matched.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
data-testid="test-span-input"
|
||||
value={input}
|
||||
onChange={(event): void => setInput(event.target.value)}
|
||||
spellCheck={false}
|
||||
aria-label="Sample span JSON"
|
||||
/>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
testId="run-test-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Play size={14} />}
|
||||
onClick={run}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert" data-testid="test-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={styles.results} data-testid="test-results">
|
||||
{result.length === 0 ? (
|
||||
<div className={styles.resultEmpty}>
|
||||
No spans returned. The mappers produced no output for this input.
|
||||
</div>
|
||||
) : (
|
||||
result.map((span, index) => (
|
||||
<TestResult
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
span={span}
|
||||
inputAttributes={testedAttributes ?? {}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestTab;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ChevronDown, ChevronRight, Pencil, Trash2 } from '@signozhq/icons';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import type { DraftGroup } from './types';
|
||||
import { conditionFiltersFromGroup } from './utils';
|
||||
|
||||
interface ColumnsConfig {
|
||||
onEditGroup: (group: DraftGroup) => void;
|
||||
onRemoveGroup: (localId: string) => void;
|
||||
onToggleGroup: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the mapping-groups TanStackTable. Sorting is off across
|
||||
// the board — the groups list API returns the full set unordered, so there's no
|
||||
// server-side ordering to back a sortable header yet.
|
||||
export function getMapperGroupsColumns({
|
||||
onEditGroup,
|
||||
onRemoveGroup,
|
||||
onToggleGroup,
|
||||
}: ColumnsConfig): TableColumnDef<DraftGroup>[] {
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Group name',
|
||||
accessorFn: (row): string => row.name,
|
||||
width: { min: 240, default: '100%' },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row, isExpanded, toggleExpanded }): JSX.Element => (
|
||||
<div className={styles.groupsTableNameCell}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
|
||||
onClick={(): void => toggleExpanded()}
|
||||
testId={`group-expand-${row.localId}`}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</Button>
|
||||
<span
|
||||
className={styles.groupsTableName}
|
||||
data-testid={`group-name-${row.localId}`}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'filters',
|
||||
header: 'Filters',
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const filters = conditionFiltersFromGroup(row);
|
||||
if (filters.length === 0) {
|
||||
return <span className={styles.muted}>No condition · always runs</span>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.groupsTableFilters}
|
||||
data-testid={`group-filters-${row.localId}`}
|
||||
>
|
||||
{filters.map((filter) => (
|
||||
<div
|
||||
className={styles.groupsTableFilter}
|
||||
key={`${filter.context}:${filter.key}`}
|
||||
>
|
||||
<Badge
|
||||
color={filter.context === 'resource' ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
>
|
||||
{filter.context}
|
||||
</Badge>
|
||||
<span className={styles.groupsTableFilterKey}>
|
||||
contains {filter.key}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
// Compact, right-aligned action cluster — opt out of the "last column
|
||||
// fills 100%" rule so the spare width flows into Group name / Filters.
|
||||
width: { fixed: '160px', ignoreLastColumnFill: true },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Edit group"
|
||||
onClick={(): void => onEditGroup(row)}
|
||||
testId={`group-edit-${row.localId}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label="Delete group"
|
||||
onClick={(): void => onRemoveGroup(row.localId)}
|
||||
testId={`group-delete-${row.localId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Switch
|
||||
value={row.enabled}
|
||||
onChange={(checked): void => onToggleGroup(row.localId, checked)}
|
||||
testId={`group-enabled-${row.localId}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Pencil, Trash2 } from '@signozhq/icons';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
import { DraftMapper, FieldContext } from './types';
|
||||
|
||||
const MAX_VISIBLE_SOURCES = 3;
|
||||
|
||||
interface ColumnsConfig {
|
||||
onEdit: (mapper: DraftMapper) => void;
|
||||
onRemove: (localId: string) => void;
|
||||
onToggle: (localId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// Column definitions for the per-group mappers TanStackTable (rendered inside an
|
||||
// expanded group row). Sorting is off — priority order is positional (top wins).
|
||||
export function getMappersColumns({
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggle,
|
||||
}: ColumnsConfig): TableColumnDef<DraftMapper>[] {
|
||||
return [
|
||||
{
|
||||
id: 'target',
|
||||
header: 'Target',
|
||||
accessorFn: (row): string => row.name,
|
||||
width: { min: 200, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<span
|
||||
className={styles.mappersTableTarget}
|
||||
data-testid={`mapper-target-${row.localId}`}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
header: 'Sources',
|
||||
width: { min: 220, default: '100%' },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => {
|
||||
// Skeleton placeholder rows reach the cell before real data, so
|
||||
// `sources` can be undefined — default to empty.
|
||||
const sources = row.sources ?? [];
|
||||
if (sources.length === 0) {
|
||||
return <span className={styles.muted}>—</span>;
|
||||
}
|
||||
const visible = sources.slice(0, MAX_VISIBLE_SOURCES);
|
||||
const remaining = sources.length - visible.length;
|
||||
return (
|
||||
<div
|
||||
className={styles.mappersTableSources}
|
||||
data-testid={`mapper-sources-${row.localId}`}
|
||||
>
|
||||
{visible.map((source) => (
|
||||
<span
|
||||
className={styles.mappersTableSourceChip}
|
||||
key={`${source.context}:${source.key}`}
|
||||
title={source.key}
|
||||
>
|
||||
{source.key}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span className={cx(styles.mappersTableSourceMore, styles.muted)}>
|
||||
+{remaining} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'writesTo',
|
||||
header: 'Writes to',
|
||||
width: { min: 130 },
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<Badge
|
||||
color={row.fieldContext === FieldContext.resource ? 'amber' : 'robin'}
|
||||
variant="outline"
|
||||
>
|
||||
{row.fieldContext}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
// Compact, right-aligned action cluster — opt out of the "last column
|
||||
// fills 100%" rule so the spare width flows into Target / Sources.
|
||||
width: { fixed: '160px', ignoreLastColumnFill: true },
|
||||
enableMove: false,
|
||||
enableRemove: false,
|
||||
cell: ({ row }): JSX.Element => (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Edit mapping"
|
||||
onClick={(): void => onEdit(row)}
|
||||
testId={`mapper-edit-${row.localId}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label="Delete mapping"
|
||||
onClick={(): void => onRemove(row.localId)}
|
||||
testId={`mapper-delete-${row.localId}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Switch
|
||||
value={row.enabled}
|
||||
onChange={(checked): void => onToggle(row.localId, checked)}
|
||||
testId={`mapper-enabled-${row.localId}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup, DraftMapper, SourceConfig } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
buildUpdatableGroup,
|
||||
buildUpdatableMapper,
|
||||
} from './utils';
|
||||
|
||||
// Thin persistence surface the store wires to the generated mutations.
|
||||
// createGroup returns the new server id so its mappers can be created under it.
|
||||
export interface SaveMutations {
|
||||
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
|
||||
updateGroup: (
|
||||
groupId: string,
|
||||
data: SpantypesUpdatableSpanMapperGroupDTO,
|
||||
) => Promise<void>;
|
||||
deleteGroup: (groupId: string) => Promise<void>;
|
||||
createMapper: (
|
||||
groupId: string,
|
||||
data: SpantypesPostableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
updateMapper: (
|
||||
groupId: string,
|
||||
mapperId: string,
|
||||
data: SpantypesUpdatableSpanMapperDTO,
|
||||
) => Promise<void>;
|
||||
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
|
||||
return (
|
||||
snapshot.name !== draft.name ||
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
!arraysEqual(snapshot.attributes, draft.attributes) ||
|
||||
!arraysEqual(snapshot.resource, draft.resource)
|
||||
);
|
||||
}
|
||||
|
||||
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
|
||||
return (
|
||||
snapshot.enabled !== draft.enabled ||
|
||||
snapshot.fieldContext !== draft.fieldContext ||
|
||||
!sourcesEqual(snapshot.sources, draft.sources)
|
||||
);
|
||||
}
|
||||
|
||||
function groupDraftOf(
|
||||
node: DraftGroup,
|
||||
): Parameters<typeof buildPostableGroup>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
attributes: node.attributes,
|
||||
resource: node.resource,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function mapperDraftOf(
|
||||
node: DraftMapper,
|
||||
): Parameters<typeof buildPostableMapper>[0] {
|
||||
return {
|
||||
id: node.serverId,
|
||||
name: node.name,
|
||||
fieldContext: node.fieldContext,
|
||||
sources: node.sources,
|
||||
enabled: node.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistMappers(
|
||||
groupServerId: string,
|
||||
snapshotMappers: DraftMapper[],
|
||||
draftMappers: DraftMapper[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => [mapper.serverId as string, mapper]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draftMappers
|
||||
.filter((mapper) => mapper.serverId)
|
||||
.map((mapper) => mapper.serverId as string),
|
||||
);
|
||||
|
||||
// Deleted mappers.
|
||||
await Promise.all(
|
||||
snapshotMappers
|
||||
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
|
||||
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
|
||||
);
|
||||
|
||||
// Created + updated mappers (sequential to keep ordering deterministic).
|
||||
for (const mapper of draftMappers) {
|
||||
if (!mapper.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.createMapper(
|
||||
groupServerId,
|
||||
buildPostableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
} else {
|
||||
const snap = snapById.get(mapper.serverId);
|
||||
if (!snap || mapperChanged(snap, mapper)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateMapper(
|
||||
groupServerId,
|
||||
mapper.serverId,
|
||||
buildUpdatableMapper(mapperDraftOf(mapper)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diffs the staged tree against the server snapshot and issues the minimal set
|
||||
// of create/update/delete calls to reconcile them.
|
||||
export async function persistDraft(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
m: SaveMutations,
|
||||
): Promise<void> {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
const draftServerIds = new Set(
|
||||
draft
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => group.serverId as string),
|
||||
);
|
||||
|
||||
// Apply additive work (creates/updates) before deletes, so a failure here
|
||||
// leaves at worst an incomplete set of additions rather than groups that
|
||||
// were deleted with no replacement persisted. Deletes are irreversible
|
||||
// (they cascade mappers server-side), so we do them last, once everything
|
||||
// else has succeeded.
|
||||
for (const group of draft) {
|
||||
if (!group.serverId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(newId, [], group.mappers, m);
|
||||
continue;
|
||||
}
|
||||
|
||||
const snap = snapById.get(group.serverId);
|
||||
if (!snap || groupChanged(snap, group)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await m.updateGroup(
|
||||
group.serverId,
|
||||
buildUpdatableGroup(groupDraftOf(group)),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
|
||||
}
|
||||
|
||||
// Deleted groups (cascades mappers server-side).
|
||||
await Promise.all(
|
||||
snapshot
|
||||
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
|
||||
.map((group) => m.deleteGroup(group.serverId as string)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesPostableSpanMapperTestGroupDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DraftGroup } from './types';
|
||||
import {
|
||||
buildPostableGroup,
|
||||
buildPostableMapper,
|
||||
groupDraftFromNode,
|
||||
mapperDraftFromNode,
|
||||
} from './utils';
|
||||
|
||||
// A group's mappers must be sent in full when the group is new (the backend has
|
||||
// no saved group of that name to backfill from) or when its mapper set has
|
||||
// diverged from the saved baseline. Otherwise we omit them and let the backend
|
||||
// load the saved mappers by group name — which also covers groups whose rows
|
||||
// were never expanded (their draft carries no mappers yet).
|
||||
function shouldSendMappers(
|
||||
snap: DraftGroup | undefined,
|
||||
group: DraftGroup,
|
||||
): boolean {
|
||||
if (!snap) {
|
||||
return true;
|
||||
}
|
||||
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
|
||||
}
|
||||
|
||||
// Builds the `groups` portion of the test request from the working draft,
|
||||
// sending each group's name/enabled/condition from the current draft and its
|
||||
// `mappers` only when they changed (null otherwise → backend backfills).
|
||||
export function buildTestGroups(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): SpantypesPostableSpanMapperTestGroupDTO[] {
|
||||
const snapById = new Map(
|
||||
snapshot
|
||||
.filter((group) => group.serverId)
|
||||
.map((group) => [group.serverId as string, group]),
|
||||
);
|
||||
|
||||
return draft.map((group) => {
|
||||
const base = buildPostableGroup(groupDraftFromNode(group));
|
||||
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
|
||||
return {
|
||||
...base,
|
||||
mappers: shouldSendMappers(snap, group)
|
||||
? group.mappers.map((mapper) =>
|
||||
buildPostableMapper(mapperDraftFromNode(mapper)),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Parses the pasted JSON into a single test span. The pasted object is treated
|
||||
// as the span's attribute map (matching the sample shown to the user); resource
|
||||
// is left empty. Throws a friendly error on anything that isn't a JSON object.
|
||||
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Paste a JSON span object to run the test.');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid JSON — check for trailing commas or missing quotes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Span must be a JSON object of attribute key-value pairs.');
|
||||
}
|
||||
|
||||
return { attributes: parsed as Record<string, unknown>, resource: {} };
|
||||
}
|
||||
|
||||
export function buildTestRequest(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
input: string,
|
||||
): SpantypesPostableSpanMapperTestDTO {
|
||||
return {
|
||||
groups: buildTestGroups(snapshot, draft),
|
||||
spans: [parseSpanInput(input)],
|
||||
};
|
||||
}
|
||||
|
||||
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
|
||||
|
||||
export interface AttrDiffEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
status: AttrChangeStatus;
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
// Diffs the span attributes a user pasted against the attributes the mappers
|
||||
// produced, so the UI can highlight which target keys got populated ('added'),
|
||||
// which source keys were consumed by a move ('removed'), and what stayed.
|
||||
// Added keys (the populated targets) sort first as the primary signal.
|
||||
export function diffSpanAttributes(
|
||||
inputAttributes: Record<string, unknown>,
|
||||
resultAttributes: Record<string, unknown>,
|
||||
): AttrDiffEntry[] {
|
||||
const added: AttrDiffEntry[] = [];
|
||||
const changed: AttrDiffEntry[] = [];
|
||||
const unchanged: AttrDiffEntry[] = [];
|
||||
const removed: AttrDiffEntry[] = [];
|
||||
|
||||
Object.entries(resultAttributes).forEach(([key, value]) => {
|
||||
if (!(key in inputAttributes)) {
|
||||
added.push({ key, value, status: 'added' });
|
||||
} else if (!valuesEqual(inputAttributes[key], value)) {
|
||||
changed.push({ key, value, status: 'changed' });
|
||||
} else {
|
||||
unchanged.push({ key, value, status: 'unchanged' });
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputAttributes).forEach(([key, value]) => {
|
||||
if (!(key in resultAttributes)) {
|
||||
removed.push({ key, value, status: 'removed' });
|
||||
}
|
||||
});
|
||||
|
||||
return [...added, ...changed, ...unchanged, ...removed];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
SpantypesFieldContextDTO,
|
||||
SpantypesSpanMapperConfigDTO,
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupConditionDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
export type MapperGroupCondition =
|
||||
NonNullable<SpantypesSpanMapperGroupConditionDTO>;
|
||||
export type Mapper = SpantypesSpanMapperDTO;
|
||||
export type MapperConfig = SpantypesSpanMapperConfigDTO;
|
||||
export type MapperSource = SpantypesSpanMapperSourceDTO;
|
||||
|
||||
export const FieldContext = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
|
||||
// A single human-readable condition clause shown in the group's Filters column.
|
||||
export interface ConditionFilter {
|
||||
context: 'attribute' | 'resource';
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
// One source candidate. `context` is where the key is read from (span
|
||||
// attribute or resource); `operation` is move (delete source) or copy (keep).
|
||||
// Priority is implicit in list order (top wins), derived on save.
|
||||
export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
}
|
||||
|
||||
// Editable form state for a mapper. `sources` is ordered highest priority
|
||||
// first; `fieldContext` is where the standardized target is written.
|
||||
export interface MapperDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Editable form state for a group. The group runs when a span carries a
|
||||
// span-attribute key matching `attributes` OR a resource key matching
|
||||
// `resource` (plain substring match).
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a mapper. `localId` is a stable client key (the server
|
||||
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
|
||||
// null until the row has been persisted.
|
||||
export interface DraftMapper {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
fieldContext: SpantypesFieldContextDTO;
|
||||
sources: SourceConfig[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Working-copy node for a group, holding its mappers inline so the whole tree
|
||||
// can be staged locally and diffed against the server snapshot on save.
|
||||
export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
useCreateSpanMapper,
|
||||
useCreateSpanMapperGroup,
|
||||
useDeleteSpanMapper,
|
||||
useDeleteSpanMapperGroup,
|
||||
useListSpanMapperGroups,
|
||||
useUpdateSpanMapper,
|
||||
useUpdateSpanMapperGroup,
|
||||
} from 'api/generated/services/spanmapper';
|
||||
|
||||
import { persistDraft, SaveMutations } from './saveDraft';
|
||||
import {
|
||||
DraftGroup,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
} from './types';
|
||||
import {
|
||||
buildDraftGroup,
|
||||
buildDraftMapper,
|
||||
nodeFromGroupDraft,
|
||||
nodeFromMapperDraft,
|
||||
} from './utils';
|
||||
|
||||
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
|
||||
|
||||
function clone(groups: DraftGroup[]): DraftGroup[] {
|
||||
return JSON.parse(JSON.stringify(groups)) as DraftGroup[];
|
||||
}
|
||||
|
||||
export interface AttributeMappingStore {
|
||||
groups: DraftGroup[];
|
||||
// The last-saved server baseline the working `groups` are diffed against.
|
||||
// Exposed so the Test tab can send only the groups whose mappers changed.
|
||||
snapshot: DraftGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
saveError: string | null;
|
||||
upsertGroup: (draft: GroupDraft) => void;
|
||||
removeGroup: (localId: string) => void;
|
||||
toggleGroup: (localId: string, enabled: boolean) => void;
|
||||
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
|
||||
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
|
||||
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
|
||||
toggleMapper: (
|
||||
groupLocalId: string,
|
||||
mapperLocalId: string,
|
||||
enabled: boolean,
|
||||
) => void;
|
||||
save: () => Promise<void>;
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useAttributeMappingStore(): AttributeMappingStore {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const groupsQuery = useListSpanMapperGroups();
|
||||
const serverGroups: MapperGroup[] = useMemo(
|
||||
() => groupsQuery.data?.data?.items ?? [],
|
||||
[groupsQuery.data],
|
||||
);
|
||||
|
||||
const ready = !groupsQuery.isLoading;
|
||||
|
||||
// A group's mappers are fetched lazily when its row is first expanded (see
|
||||
// MappersTable -> hydrateGroupMappers) and cached here, keyed by group server
|
||||
// id. Page load stays a single groups request — never an N+1 fan-out across
|
||||
// every group.
|
||||
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
|
||||
{},
|
||||
);
|
||||
const loadedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const snapshot = useMemo<DraftGroup[]>(() => {
|
||||
if (!ready) {
|
||||
return [];
|
||||
}
|
||||
return serverGroups.map((group) =>
|
||||
buildDraftGroup(group, loadedMappers[group.id] ?? []),
|
||||
);
|
||||
}, [ready, serverGroups, loadedMappers]);
|
||||
|
||||
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
|
||||
|
||||
// Initialise the working copy once data is ready (and re-init after a save
|
||||
// clears it). Never clobbers in-flight edits — only runs when draft is null.
|
||||
useEffect(() => {
|
||||
if (ready && draft === null) {
|
||||
setDraft(clone(snapshot));
|
||||
}
|
||||
}, [ready, draft, snapshot]);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
|
||||
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
|
||||
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
|
||||
const { mutateAsync: createMapper } = useCreateSpanMapper();
|
||||
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
|
||||
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
|
||||
|
||||
const mutations: SaveMutations = useMemo(
|
||||
() => ({
|
||||
createGroup: async (data): Promise<string> => {
|
||||
const result = await createGroup({ data });
|
||||
return result.data.id;
|
||||
},
|
||||
updateGroup: async (groupId, data): Promise<void> => {
|
||||
await updateGroup({ pathParams: { groupId }, data });
|
||||
},
|
||||
deleteGroup: async (groupId): Promise<void> => {
|
||||
await deleteGroup({ pathParams: { groupId } });
|
||||
},
|
||||
createMapper: async (groupId, data): Promise<void> => {
|
||||
await createMapper({ pathParams: { groupId }, data });
|
||||
},
|
||||
updateMapper: async (groupId, mapperId, data): Promise<void> => {
|
||||
await updateMapper({ pathParams: { groupId, mapperId }, data });
|
||||
},
|
||||
deleteMapper: async (groupId, mapperId): Promise<void> => {
|
||||
await deleteMapper({ pathParams: { groupId, mapperId } });
|
||||
},
|
||||
}),
|
||||
[
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
createMapper,
|
||||
updateMapper,
|
||||
deleteMapper,
|
||||
],
|
||||
);
|
||||
|
||||
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
|
||||
setDraft((prev) => {
|
||||
const groups = prev ?? [];
|
||||
if (groupDraft.id) {
|
||||
return groups.map((group) =>
|
||||
group.localId === groupDraft.id
|
||||
? nodeFromGroupDraft(groupDraft, group)
|
||||
: group,
|
||||
);
|
||||
}
|
||||
return [...groups, nodeFromGroupDraft(groupDraft)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeGroup = useCallback((localId: string): void => {
|
||||
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === localId ? { ...group, enabled } : group,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Fold a group's freshly-fetched server mappers into both the snapshot
|
||||
// baseline and the working draft, exactly once per group (the guard skips
|
||||
// re-expands). The "Add mapping" affordance renders while the fetch is still
|
||||
// in flight, so a user can stage a new mapper (serverId === null) before the
|
||||
// server list arrives — those unsaved rows are preserved, with the server
|
||||
// mappers folded in ahead of them, so a racing add isn't clobbered.
|
||||
const hydrateGroupMappers = useCallback(
|
||||
(groupServerId: string, mappers: Mapper[]): void => {
|
||||
if (loadedRef.current.has(groupServerId)) {
|
||||
return;
|
||||
}
|
||||
loadedRef.current.add(groupServerId);
|
||||
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
|
||||
setDraft((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: prev.map((group) =>
|
||||
group.serverId === groupServerId
|
||||
? {
|
||||
...group,
|
||||
mappers: [
|
||||
...mappers.map(buildDraftMapper),
|
||||
...group.mappers.filter((mapper) => mapper.serverId === null),
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const upsertMapper = useCallback(
|
||||
(groupLocalId: string, mapperDraft: MapperDraft): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) => {
|
||||
if (group.localId !== groupLocalId) {
|
||||
return group;
|
||||
}
|
||||
if (mapperDraft.id) {
|
||||
return {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperDraft.id
|
||||
? nodeFromMapperDraft(mapperDraft, mapper)
|
||||
: mapper,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.filter(
|
||||
(mapper) => mapper.localId !== mapperLocalId,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleMapper = useCallback(
|
||||
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
|
||||
setDraft((prev) =>
|
||||
(prev ?? []).map((group) =>
|
||||
group.localId === groupLocalId
|
||||
? {
|
||||
...group,
|
||||
mappers: group.mappers.map((mapper) =>
|
||||
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
|
||||
),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const discard = useCallback((): void => {
|
||||
setSaveError(null);
|
||||
setDraft(clone(snapshot));
|
||||
}, [snapshot]);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistDraft(snapshot, draft, mutations);
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
typeof query.queryKey?.[0] === 'string' &&
|
||||
(query.queryKey[0] as string).startsWith(GROUPS_KEY_PREFIX),
|
||||
});
|
||||
// Drop lazily-loaded mappers and re-initialise the working copy from the
|
||||
// freshly-fetched server data; expanded rows re-hydrate on next render.
|
||||
loadedRef.current = new Set();
|
||||
setLoadedMappers({});
|
||||
setDraft(null);
|
||||
toast.success('Attribute mapping changes saved');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
toast.error(`Failed to save changes: ${message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draft, snapshot, mutations, queryClient]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => draft !== null && JSON.stringify(draft) !== JSON.stringify(snapshot),
|
||||
[draft, snapshot],
|
||||
);
|
||||
|
||||
return {
|
||||
groups: draft ?? [],
|
||||
snapshot,
|
||||
isLoading: !ready || draft === null,
|
||||
isError: groupsQuery.isError,
|
||||
isDirty,
|
||||
isSaving,
|
||||
saveError,
|
||||
upsertGroup,
|
||||
removeGroup,
|
||||
toggleGroup,
|
||||
hydrateGroupMappers,
|
||||
upsertMapper,
|
||||
removeMapper,
|
||||
toggleMapper,
|
||||
save,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
|
||||
|
||||
interface UseGroupFormDrawer {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: GroupDraft;
|
||||
setDraft: (next: GroupDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (group: DraftGroup) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the group drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useGroupFormDrawer(): UseGroupFormDrawer {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_GROUP_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((group: DraftGroup): void => {
|
||||
setMode('edit');
|
||||
setDraft(groupDraftFromNode(group));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
|
||||
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
|
||||
|
||||
interface UseMapperFormDrawerResult {
|
||||
isOpen: boolean;
|
||||
mode: MapperDraftMode;
|
||||
draft: MapperDraft;
|
||||
setDraft: (next: MapperDraft) => void;
|
||||
openForAdd: () => void;
|
||||
openForEdit: (mapper: DraftMapper) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
// Form state for the mapper drawer. Persistence is staged through the store,
|
||||
// so this hook only owns open/draft/mode.
|
||||
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<MapperDraftMode>('add');
|
||||
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
|
||||
|
||||
const openForAdd = useCallback((): void => {
|
||||
setMode('add');
|
||||
setDraft(EMPTY_MAPPER_DRAFT);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const openForEdit = useCallback((mapper: DraftMapper): void => {
|
||||
setMode('edit');
|
||||
setDraft(mapperDraftFromNode(mapper));
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesSpanMapperTestSpanDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { buildTestRequest } from './testPayload';
|
||||
import { DraftGroup } from './types';
|
||||
|
||||
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
}`;
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
|
||||
return (
|
||||
axiosError?.response?.data?.error?.message ??
|
||||
(error instanceof Error ? error.message : 'Test failed. Please try again.')
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseTestSpanMapper {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
run: () => void;
|
||||
reset: () => void;
|
||||
isRunning: boolean;
|
||||
result: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
// The attributes that were actually submitted with the last successful run,
|
||||
// so the result diff stays stable even if the textarea is edited afterwards.
|
||||
testedAttributes: Record<string, unknown> | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
|
||||
// request, and the result/error of the test mutation. Builds the request from
|
||||
// the working draft (sending only changed groups' mappers — see testPayload).
|
||||
export function useTestSpanMapper(
|
||||
snapshot: DraftGroup[],
|
||||
draft: DraftGroup[],
|
||||
): UseTestSpanMapper {
|
||||
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
|
||||
null,
|
||||
);
|
||||
const [testedAttributes, setTestedAttributes] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
|
||||
const { mutate, isLoading } = useTestSpanMappers();
|
||||
|
||||
const run = useCallback((): void => {
|
||||
setError(null);
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildTestRequest(snapshot, draft, input);
|
||||
} catch (parseError) {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(parseError));
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
mutate(
|
||||
{ data: body },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
setTestedAttributes(submittedAttributes);
|
||||
setResult(response.data?.spans ?? []);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setResult(null);
|
||||
setError(apiErrorMessage(mutationError));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [snapshot, draft, input, mutate]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTestedAttributes(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
run,
|
||||
reset,
|
||||
isRunning: isLoading,
|
||||
result,
|
||||
testedAttributes,
|
||||
error,
|
||||
};
|
||||
}
|
||||
264
frontend/src/container/LLMObservabilityAttributeMapping/utils.ts
Normal file
264
frontend/src/container/LLMObservabilityAttributeMapping/utils.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionFilter,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
GroupDraft,
|
||||
Mapper,
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
|
||||
// with a server UUID and is easy to spot in logs.
|
||||
export function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
export function cleanKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Display clauses for a group's condition keys (span attribute keys first,
|
||||
// then resource keys).
|
||||
export function conditionFiltersFromGroup(group: {
|
||||
attributes?: string[];
|
||||
resource?: string[];
|
||||
}): ConditionFilter[] {
|
||||
// TanStackTable renders skeleton placeholder rows through the cells on first
|
||||
// render, so these arrays can be undefined before real data lands — default
|
||||
// to empty rather than crashing the cell.
|
||||
return [
|
||||
...(group.attributes ?? []).map((key) => ({
|
||||
context: 'attribute' as const,
|
||||
key,
|
||||
})),
|
||||
...(group.resource ?? []).map((key) => ({
|
||||
context: 'resource' as const,
|
||||
key,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
export function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
const sources = mapper.config?.sources ?? [];
|
||||
return [...sources]
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.map((source) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEmptySource(): SourceConfig {
|
||||
return {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
fieldContext: FieldContext.attribute,
|
||||
sources: [createEmptySource()],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Trimmed, de-duplicated (by context+key), non-empty sources in priority order,
|
||||
// preserving each source's context and operation. A key identifies a different
|
||||
// source per context (span attribute vs resource), so both can coexist; only an
|
||||
// exact (context, key) repeat is collapsed, keeping the higher-priority row.
|
||||
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const seen = new Set<string>();
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...source, key });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isMapperDraftValid(draft: MapperDraft): boolean {
|
||||
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
|
||||
}
|
||||
|
||||
// Priority is derived from list order so the first row wins.
|
||||
function buildSources(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO['config']['sources'] {
|
||||
const sources = getCleanSources(draft);
|
||||
return sources.map((source, index) => ({
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPostableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesPostableSpanMapperDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
// The target name is immutable on update (UpdatableSpanMapper has no name).
|
||||
export function buildUpdatableMapper(
|
||||
draft: MapperDraft,
|
||||
): SpantypesUpdatableSpanMapperDTO {
|
||||
return {
|
||||
fieldContext: draft.fieldContext,
|
||||
enabled: draft.enabled,
|
||||
config: { sources: buildSources(draft) },
|
||||
};
|
||||
}
|
||||
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [''],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function isGroupDraftValid(draft: GroupDraft): boolean {
|
||||
return draft.name.trim().length > 0;
|
||||
}
|
||||
|
||||
export function buildPostableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesPostableSpanMapperGroupDTO {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
enabled: draft.enabled,
|
||||
condition: {
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A full group payload is also a valid partial-update payload (all updatable
|
||||
// fields are present), so we reuse the postable builder.
|
||||
export function buildUpdatableGroup(
|
||||
draft: GroupDraft,
|
||||
): SpantypesUpdatableSpanMapperGroupDTO {
|
||||
return buildPostableGroup(draft);
|
||||
}
|
||||
|
||||
// ---- working-copy (draft tree) helpers ----
|
||||
|
||||
export function buildDraftMapper(mapper: Mapper): DraftMapper {
|
||||
return {
|
||||
localId: mapper.id,
|
||||
serverId: mapper.id,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources: getMapperSources(mapper),
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftGroup(
|
||||
group: MapperGroup,
|
||||
mappers: Mapper[],
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
}
|
||||
|
||||
// DraftGroup -> editable form state (id carries the localId).
|
||||
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// DraftMapper -> editable form state (id carries the localId).
|
||||
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
|
||||
return {
|
||||
id: mapper.localId,
|
||||
name: mapper.name,
|
||||
fieldContext: mapper.fieldContext,
|
||||
sources:
|
||||
mapper.sources.length > 0
|
||||
? mapper.sources.map((source) => ({ ...source }))
|
||||
: [createEmptySource()],
|
||||
enabled: mapper.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
|
||||
// staged tree already holds normalized values.
|
||||
export function nodeFromGroupDraft(
|
||||
draft: GroupDraft,
|
||||
existing?: DraftGroup,
|
||||
): DraftGroup {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('group'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
attributes: cleanKeys(draft.attributes),
|
||||
resource: cleanKeys(draft.resource),
|
||||
enabled: draft.enabled,
|
||||
mappers: existing?.mappers ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeFromMapperDraft(
|
||||
draft: MapperDraft,
|
||||
existing?: DraftMapper,
|
||||
): DraftMapper {
|
||||
return {
|
||||
localId: existing?.localId ?? genLocalId('mapper'),
|
||||
serverId: existing?.serverId ?? null,
|
||||
name: draft.name.trim(),
|
||||
fieldContext: draft.fieldContext,
|
||||
sources: getCleanSources(draft),
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
}
|
||||
@@ -206,6 +206,7 @@ export const routesToSkip = [
|
||||
ROUTES.METER,
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -2,16 +2,15 @@ import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Editor from 'components/Editor';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import { sortDirectionOf } from '../variableModel';
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
sort: VariableSortDTO;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
@@ -37,10 +36,7 @@ function QueryVariableFields({
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sortDirectionOf(sort)) as (
|
||||
| string
|
||||
| number
|
||||
)[],
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
|
||||
@@ -12,12 +12,10 @@ import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
sortDirectionOf,
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
@@ -25,16 +23,10 @@ import QueryVariableFields from './QueryVariableFields';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
const SORT_LABEL: Record<VariableSortDTO, string> = {
|
||||
[VariableSortDTO.none]: 'Disabled',
|
||||
[VariableSortDTO['alphabetical-asc']]: 'Alphabetical (asc)',
|
||||
[VariableSortDTO['alphabetical-desc']]: 'Alphabetical (desc)',
|
||||
[VariableSortDTO['numerical-asc']]: 'Numerical (asc)',
|
||||
[VariableSortDTO['numerical-desc']]: 'Numerical (desc)',
|
||||
[VariableSortDTO['alphabetical-ci-asc']]:
|
||||
'Alphabetical, case-insensitive (asc)',
|
||||
[VariableSortDTO['alphabetical-ci-desc']]:
|
||||
'Alphabetical, case-insensitive (desc)',
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
@@ -99,10 +91,7 @@ function VariableForm({
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), sortDirectionOf(model.sort)) as (
|
||||
| string
|
||||
| number
|
||||
)[],
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -270,7 +259,7 @@ function VariableForm({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSortDTO })}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import {
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
DashboardtypesListVariableSpecSortDTO as VariableSortDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardtypesTextVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
@@ -35,7 +35,7 @@ export function dtoToFormModel(
|
||||
|
||||
// Text variable — a distinct envelope (no list plugin).
|
||||
if (dto.kind === TextEnvelopeKind.TextVariable) {
|
||||
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...common,
|
||||
type: 'TEXT',
|
||||
@@ -50,7 +50,7 @@ export function dtoToFormModel(
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: spec.sort ?? VariableSortDTO.none,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { DashboardtypesListVariableSpecSortDTO as VariableSortDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TSortVariableValuesType } from 'types/api/dashboard/getAll';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
@@ -10,6 +8,8 @@ import type { TSortVariableValuesType } from 'types/api/dashboard/getAll';
|
||||
|
||||
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). */
|
||||
@@ -24,20 +24,7 @@ export const PLUGIN_KIND = {
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSortDTO[] = Object.values(VariableSortDTO);
|
||||
|
||||
/** Direction the preview sorter should apply for a given wire sort value. */
|
||||
export function sortDirectionOf(
|
||||
sort: VariableSortDTO,
|
||||
): TSortVariableValuesType {
|
||||
if (sort.endsWith('-asc')) {
|
||||
return 'ASC';
|
||||
}
|
||||
if (sort.endsWith('-desc')) {
|
||||
return 'DESC';
|
||||
}
|
||||
return 'DISABLED';
|
||||
}
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
@@ -55,7 +42,7 @@ export interface VariableFormModel {
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSortDTO;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
@@ -80,7 +67,7 @@ export function emptyVariableFormModel(): VariableFormModel {
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: VariableSortDTO.none,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import LLMObservabilityAttributeMapping from 'container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping';
|
||||
|
||||
function LLMObservabilityAttributeMappingPage(): JSX.Element {
|
||||
return <LLMObservabilityAttributeMapping />;
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMappingPage;
|
||||
@@ -136,4 +136,5 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
|
||||
FillMode: FillModeSolid,
|
||||
SpanGaps: SpanGaps{FillLessThan: valuer.MustParseTextDuration("60s")},
|
||||
},
|
||||
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
|
||||
Legend: Legend{Position: LegendPositionBottom},
|
||||
},
|
||||
},
|
||||
Queries: []Query{
|
||||
|
||||
@@ -48,42 +48,7 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (d *DashboardSpec) Validate() error {
|
||||
if err := d.validateVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.validatePanels(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.validateLayouts()
|
||||
}
|
||||
|
||||
// validateVariables rejects two variables sharing the same name.
|
||||
func (d *DashboardSpec) validateVariables() error {
|
||||
seen := make(map[string]struct{}, len(d.Variables))
|
||||
for i, v := range d.Variables {
|
||||
var name string
|
||||
switch s := v.Spec.(type) {
|
||||
case *ListVariableSpec:
|
||||
name = s.Name
|
||||
case *TextVariableSpec:
|
||||
name = s.Name
|
||||
default:
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validatePanels() error {
|
||||
for key, panel := range d.Panels {
|
||||
if err := common.ValidateID(key); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
|
||||
}
|
||||
if panel == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
|
||||
}
|
||||
@@ -104,13 +69,6 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
}
|
||||
|
||||
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
|
||||
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
|
||||
}
|
||||
if !slices.Contains(allowed, plugin.Kind) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
|
||||
@@ -138,35 +96,12 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
for li, layout := range d.Layouts {
|
||||
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
|
||||
if !ok {
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
for ii, item := range grid.Items {
|
||||
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
|
||||
if item.Content == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: content reference is required", path)
|
||||
}
|
||||
key, err := panelKeyFromRef(item.Content.Path, item.Content.Ref, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Panels[key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
|
||||
}
|
||||
}
|
||||
var (
|
||||
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// panelKeyFromRef extracts <key> from a "#/spec/panels/<key>" content ref.
|
||||
func panelKeyFromRef(refPath []string, ref string, path string) (string, error) {
|
||||
if len(refPath) != 3 || refPath[0] != "spec" || refPath[1] != "panels" {
|
||||
return "", errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %q must reference a panel as \"#/spec/panels/<key>\"", path, ref)
|
||||
}
|
||||
return refPath[2], nil
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV
|
||||
}
|
||||
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard").WithAdditional(err.Error())
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
|
||||
}
|
||||
out := &UpdatableDashboardV2{}
|
||||
if err := json.Unmarshal(patched, out); err != nil {
|
||||
|
||||
@@ -405,7 +405,6 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -112,174 +112,6 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
require.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {"name": "env", "value": "prod"}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "env",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for duplicate variable name")
|
||||
require.Contains(t, err.Error(), `duplicate variable name "env"`)
|
||||
}
|
||||
|
||||
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
listVarWithName := func(name string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "` + name + `",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
for _, name := range []string{"my var", "cost$", "bad!", "a/b"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.Error(t, err, "expected error for invalid variable name %q", name)
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.NoError(t, err, "expected valid variable name %q", name)
|
||||
})
|
||||
}
|
||||
t.Run("digits only", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName("123"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot contain only digits")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidatePanelKey(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"bad key!": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for invalid panel key")
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
}
|
||||
|
||||
func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
listVar := func(specFields string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "service",
|
||||
` + specFields + `
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("valid sort is accepted", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("unknown sort is rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown sort")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"text variable": []byte(`{
|
||||
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
|
||||
"layouts": []
|
||||
}`),
|
||||
"list variable": []byte(`{
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
|
||||
}
|
||||
}],
|
||||
"layouts": []
|
||||
}`),
|
||||
}
|
||||
for name, data := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for empty variable name")
|
||||
require.Contains(t, err.Error(), "name cannot be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -438,65 +270,6 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
|
||||
}
|
||||
|
||||
func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
validPanels := `"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}`
|
||||
layout := func(items string) []byte {
|
||||
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "reference to unknown panel",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/ghost"}}`),
|
||||
wantContain: `references unknown panel "ghost"`,
|
||||
},
|
||||
{
|
||||
name: "reference not pointing at a panel",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/variables/p1"}}`),
|
||||
wantContain: "must reference a panel",
|
||||
},
|
||||
{
|
||||
name: "reference missing spec prefix",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/panels/p1"}}`),
|
||||
wantContain: "must reference a panel",
|
||||
},
|
||||
{
|
||||
name: "valid reference",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}`),
|
||||
wantContain: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(tt.data)
|
||||
if tt.wantContain == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -796,24 +569,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}`,
|
||||
wantContain: "legend position",
|
||||
},
|
||||
{
|
||||
name: "bad legend mode",
|
||||
data: `{
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BarChartPanel",
|
||||
"spec": {"legend": {"mode": "grid"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "legend mode",
|
||||
},
|
||||
{
|
||||
name: "bad threshold format",
|
||||
data: `{
|
||||
@@ -879,39 +634,6 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Label on ThresholdWithLabel is optional — the backend never reads it, so a
|
||||
// threshold with an omitted or empty label must validate cleanly.
|
||||
func TestThresholdLabelOptional(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
threshold string
|
||||
}{
|
||||
{name: "label omitted", threshold: `{"value": 100, "color": "Red"}`},
|
||||
{name: "label empty", threshold: `{"value": 100, "color": "Red", "label": ""}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "threshold without a label should validate")
|
||||
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
@@ -1027,6 +749,11 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
|
||||
wantContain: "Color",
|
||||
},
|
||||
{
|
||||
name: "ThresholdWithLabel missing label",
|
||||
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
|
||||
wantContain: "Label",
|
||||
},
|
||||
{
|
||||
name: "ComparisonThreshold missing value",
|
||||
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
|
||||
@@ -1084,11 +811,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
|
||||
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
|
||||
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
|
||||
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
|
||||
|
||||
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
|
||||
// and verify the output contains the default values.
|
||||
@@ -1099,10 +825,9 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
"decimalPrecision": `"2"`,
|
||||
"lineInterpolation": `"spline"`,
|
||||
"lineStyle": `"solid"`,
|
||||
"fillMode": `"none"`,
|
||||
"fillMode": `"solid"`,
|
||||
"timePreference": `"global_time"`,
|
||||
"position": `"bottom"`,
|
||||
"mode": `"list"`,
|
||||
} {
|
||||
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
|
||||
}
|
||||
@@ -1205,7 +930,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
|
||||
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
|
||||
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
|
||||
assert.Equal(t, "none", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
|
||||
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
|
||||
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
|
||||
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
|
||||
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
@@ -1225,7 +950,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "none", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
|
||||
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
@@ -1241,7 +966,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
"decimalPrecision": `"2"`,
|
||||
"lineInterpolation": `"spline"`,
|
||||
"lineStyle": `"solid"`,
|
||||
"fillMode": `"none"`,
|
||||
"fillMode": `"solid"`,
|
||||
"timePreference": `"global_time"`,
|
||||
"position": `"bottom"`,
|
||||
"format": `"text"`,
|
||||
|
||||
@@ -30,7 +30,6 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
|
||||
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
|
||||
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
|
||||
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
|
||||
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
|
||||
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = PanelPluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = QueryPluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = VariablePluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = DatasourcePluginKind(kind)
|
||||
p.Spec = *spec
|
||||
p.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -297,7 +297,8 @@ func extractKindAndSpec(data []byte) (string, []byte, error) {
|
||||
return head.Kind, head.Spec, nil
|
||||
}
|
||||
|
||||
func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
|
||||
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
|
||||
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
|
||||
if len(specJSON) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
|
||||
}
|
||||
@@ -309,12 +310,7 @@ func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
|
||||
if err := validator.New().Struct(target); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
|
||||
}
|
||||
if v, ok := any(target).(interface{ validate() error }); ok {
|
||||
if err := v.validate(); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: %s", kind, err.Error())
|
||||
}
|
||||
}
|
||||
return &target, nil
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators
|
||||
|
||||
@@ -4,11 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
@@ -86,7 +84,7 @@ type QuerySpec struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
|
||||
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// discriminated oneOf (see JSONSchemaOneOf).
|
||||
type Variable struct {
|
||||
Kind variable.Kind `json:"kind" required:"true"`
|
||||
@@ -96,7 +94,7 @@ type Variable struct {
|
||||
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(variable.KindList): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec"),
|
||||
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec"),
|
||||
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,14 +110,14 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
v.Kind = variable.KindList
|
||||
v.Spec = *spec
|
||||
v.Spec = spec
|
||||
case string(variable.KindText):
|
||||
spec, err := decodeSpec(specJSON, new(TextVariableSpec), kind)
|
||||
spec, err := decodeSpec(specJSON, new(dashboard.TextVariableSpec), kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Kind = variable.KindText
|
||||
v.Spec = *spec
|
||||
v.Spec = spec
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable kind %q; allowed values: %s", kind, allowedValuesForKind([]variable.Kind{variable.KindList, variable.KindText}))
|
||||
}
|
||||
@@ -129,7 +127,7 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
|
||||
func (Variable) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
|
||||
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
|
||||
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,106 +143,15 @@ 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 *Display `json:"display,omitempty"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
CustomAllValue string `json:"customAllValue,omitempty"`
|
||||
CapturingRegexp string `json:"capturingRegexp,omitempty"`
|
||||
Sort ListVariableSpecSort `json:"sort,omitzero"`
|
||||
Sort *variable.Sort `json:"sort,omitempty"`
|
||||
Plugin VariablePlugin `json:"plugin"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
|
||||
// check perses only applies to text variables); run by decodeSpec on unmarshal.
|
||||
func (s *ListVariableSpec) validate() error {
|
||||
if err := common.ValidateID(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := strconv.Atoi(s.Name); err == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
|
||||
}
|
||||
if s.CustomAllValue != "" && !s.AllowAllValue {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
|
||||
}
|
||||
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVariableSpecSort is the value-list sort method, mirrored from Perses as a
|
||||
// stable enum so the allowed values surface in the generated OpenAPI schema.
|
||||
type ListVariableSpecSort struct{ valuer.String }
|
||||
|
||||
var (
|
||||
SortNone = ListVariableSpecSort{valuer.NewString("none")}
|
||||
SortAlphabeticalAsc = ListVariableSpecSort{valuer.NewString("alphabetical-asc")}
|
||||
SortAlphabeticalDesc = ListVariableSpecSort{valuer.NewString("alphabetical-desc")}
|
||||
SortNumericalAsc = ListVariableSpecSort{valuer.NewString("numerical-asc")}
|
||||
SortNumericalDesc = ListVariableSpecSort{valuer.NewString("numerical-desc")}
|
||||
SortAlphabeticalCaseInsensitiveAsc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-asc")}
|
||||
SortAlphabeticalCaseInsensitiveDesc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-desc")}
|
||||
)
|
||||
|
||||
func (ListVariableSpecSort) Enum() []any {
|
||||
return []any{
|
||||
SortNone,
|
||||
SortAlphabeticalAsc,
|
||||
SortAlphabeticalDesc,
|
||||
SortNumericalAsc,
|
||||
SortNumericalDesc,
|
||||
SortAlphabeticalCaseInsensitiveAsc,
|
||||
SortAlphabeticalCaseInsensitiveDesc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ListVariableSpecSort) IsValid() bool {
|
||||
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates against the enum on decode (valuer.String alone
|
||||
// accepts any string). An empty value is allowed and means "no sort", matching
|
||||
// Perses.
|
||||
func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid sort: must be a string, one of `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`")
|
||||
}
|
||||
if v == "" {
|
||||
*s = ListVariableSpecSort{}
|
||||
return nil
|
||||
}
|
||||
sort := ListVariableSpecSort{valuer.NewString(v)}
|
||||
if !sort.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown sort %q: must be `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`", v)
|
||||
}
|
||||
*s = sort
|
||||
return nil
|
||||
}
|
||||
|
||||
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
|
||||
// required/non-empty schema tags perses leaves off.
|
||||
type TextVariableSpec struct {
|
||||
Display *Display `json:"display,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Constant bool `json:"constant,omitempty"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
|
||||
func (s *TextVariableSpec) validate() error {
|
||||
if err := common.ValidateID(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := strconv.Atoi(s.Name); err == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
|
||||
}
|
||||
if s.Value == "" && s.Constant {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
|
||||
}
|
||||
return nil
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -287,7 +194,7 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
l.Kind = dashboard.LayoutKind(kind)
|
||||
l.Spec = *spec
|
||||
l.Spec = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,6 @@ type TableFormatting struct {
|
||||
|
||||
type Legend struct {
|
||||
Position LegendPosition `json:"position"`
|
||||
Mode LegendMode `json:"mode"`
|
||||
CustomColors map[string]string `json:"customColors"`
|
||||
}
|
||||
|
||||
@@ -249,7 +248,7 @@ type ThresholdWithLabel struct {
|
||||
Value float64 `json:"value" validate:"required" required:"true"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
Label string `json:"label" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
@@ -359,47 +358,6 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type LegendMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
LegendModeList = LegendMode{valuer.NewString("list")} // default
|
||||
LegendModeTable = LegendMode{valuer.NewString("table")}
|
||||
)
|
||||
|
||||
func (LegendMode) Enum() []any {
|
||||
return []any{LegendModeList} // others are not supported in UI yet
|
||||
}
|
||||
|
||||
func (m LegendMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return LegendModeList.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m LegendMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *LegendMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend mode: must be a string, one of `list` or `table`")
|
||||
}
|
||||
if v == "" {
|
||||
*m = LegendModeList
|
||||
return nil
|
||||
}
|
||||
lm := LegendMode{valuer.NewString(v)}
|
||||
switch lm {
|
||||
case LegendModeList, LegendModeTable:
|
||||
*m = lm
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend mode %q: must be `list` or `table`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type ThresholdFormat struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -576,9 +534,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
FillModeSolid = FillMode{valuer.NewString("solid")}
|
||||
FillModeSolid = FillMode{valuer.NewString("solid")} // default
|
||||
FillModeGradient = FillMode{valuer.NewString("gradient")}
|
||||
FillModeNone = FillMode{valuer.NewString("none")} // default
|
||||
FillModeNone = FillMode{valuer.NewString("none")}
|
||||
)
|
||||
|
||||
func (FillMode) Enum() []any {
|
||||
@@ -587,7 +545,7 @@ func (FillMode) Enum() []any {
|
||||
|
||||
func (fm FillMode) ValueOrDefault() string {
|
||||
if fm.IsZero() {
|
||||
return FillModeNone.StringValue()
|
||||
return FillModeSolid.StringValue()
|
||||
}
|
||||
return fm.StringValue()
|
||||
}
|
||||
@@ -602,7 +560,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
|
||||
}
|
||||
if v == "" {
|
||||
*fm = FillModeNone
|
||||
*fm = FillModeSolid
|
||||
return nil
|
||||
}
|
||||
val := FillMode{valuer.NewString(v)}
|
||||
@@ -615,9 +573,12 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// SpanGaps controls whether lines connect across null values.
|
||||
// When FillOnlyBelow is false (default), all gaps are connected.
|
||||
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan valuer.TextDuration `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow"`
|
||||
FillLessThan valuer.TextDuration `json:"fillLessThan"`
|
||||
}
|
||||
|
||||
type PrecisionOption struct{ valuer.String }
|
||||
|
||||
Reference in New Issue
Block a user