mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-28 22:50:32 +01:00
Compare commits
140 Commits
feat/ai-as
...
infraM/v2_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9203602abd | ||
|
|
c10d278ec4 | ||
|
|
8b0e8f666e | ||
|
|
1d89b03f10 | ||
|
|
ffd493617f | ||
|
|
3f95d35ad5 | ||
|
|
9e5678d6b3 | ||
|
|
7d702763cc | ||
|
|
3a61a78986 | ||
|
|
941016f12c | ||
|
|
46e833faba | ||
|
|
4bd7492629 | ||
|
|
500ce85ccb | ||
|
|
d1c9864f52 | ||
|
|
f80b650390 | ||
|
|
401701e036 | ||
|
|
3bec0df0ad | ||
|
|
24fe9a986d | ||
|
|
520e92049c | ||
|
|
92d297ac9d | ||
|
|
eff29aefba | ||
|
|
ca73453c9e | ||
|
|
1d836d674f | ||
|
|
83724b0cde | ||
|
|
3050e37ec7 | ||
|
|
bdbaa32485 | ||
|
|
55b2215025 | ||
|
|
e90378e618 | ||
|
|
4592b78f48 | ||
|
|
d81c99feae | ||
|
|
65a456ff9e | ||
|
|
264577b673 | ||
|
|
b35c6676f9 | ||
|
|
c78c9a42db | ||
|
|
1095caa123 | ||
|
|
9043b49762 | ||
|
|
d4084a7494 | ||
|
|
27c564b3bf | ||
|
|
f02c491828 | ||
|
|
3d53b8f77f | ||
|
|
dffe94fec4 | ||
|
|
b7d4f18aae | ||
|
|
9ad2ec428a | ||
|
|
c9360fcf13 | ||
|
|
b5ab45db20 | ||
|
|
08f76aca78 | ||
|
|
983d4fe4f2 | ||
|
|
833af794c3 | ||
|
|
21b51d1fcc | ||
|
|
56f22682c8 | ||
|
|
9c8359940c | ||
|
|
4050880275 | ||
|
|
5e775f64f2 | ||
|
|
0189f23f46 | ||
|
|
49a36d4e3d | ||
|
|
9407d658ab | ||
|
|
5035712485 | ||
|
|
bab17c3615 | ||
|
|
37b44f4db9 | ||
|
|
99dd6e5f1e | ||
|
|
9c7131fa6a | ||
|
|
ad889a2e1d | ||
|
|
a4f6d0cbf5 | ||
|
|
589bed7c16 | ||
|
|
93843a1f48 | ||
|
|
88c43108fc | ||
|
|
ed4cf540e8 | ||
|
|
9e2dfa9033 | ||
|
|
d98d5d68ee | ||
|
|
2cb1c3b73b | ||
|
|
ae7ca497ad | ||
|
|
a579916961 | ||
|
|
4a16d56abf | ||
|
|
642b5ac3f0 | ||
|
|
a12112619c | ||
|
|
014785f1bc | ||
|
|
58ee797b10 | ||
|
|
82d236742f | ||
|
|
397e1ad5be | ||
|
|
8d6b25ca9b | ||
|
|
5fa6bd8b8d | ||
|
|
bd9977483b | ||
|
|
50fbdfeeef | ||
|
|
e2b1b73e87 | ||
|
|
cb9f3fd3e5 | ||
|
|
232acc343d | ||
|
|
2025afdccc | ||
|
|
d2f4d4af93 | ||
|
|
47ff7bbb8e | ||
|
|
724071c5dc | ||
|
|
4d24979358 | ||
|
|
042943b10a | ||
|
|
48a9be7ec8 | ||
|
|
a9504b2120 | ||
|
|
8755887c4a | ||
|
|
4cb4662b3a | ||
|
|
e6900dabc8 | ||
|
|
c1ba389b63 | ||
|
|
3a1f40234f | ||
|
|
2e4891fa63 | ||
|
|
04ebc0bec7 | ||
|
|
271f9b81ed | ||
|
|
6fa815c294 | ||
|
|
63ec518efb | ||
|
|
c4ca20dd90 | ||
|
|
e56cc4222b | ||
|
|
07d2944d7c | ||
|
|
dea01ae36a | ||
|
|
62ea5b54e2 | ||
|
|
e549a7e42f | ||
|
|
90e2ebb11f | ||
|
|
61baa1be7a | ||
|
|
b946fa665f | ||
|
|
2e049556e4 | ||
|
|
492a5e70d7 | ||
|
|
ba1f2771e8 | ||
|
|
7458fb4855 | ||
|
|
5f55f3938b | ||
|
|
3e8102485c | ||
|
|
861c682ea5 | ||
|
|
c8e5895dff | ||
|
|
82d72e7edb | ||
|
|
a3f8ecaaf1 | ||
|
|
19aada656c | ||
|
|
b21bb4280f | ||
|
|
bc0a4fdb5c | ||
|
|
37fb0e9254 | ||
|
|
aecfa1a174 | ||
|
|
b869d23d94 | ||
|
|
6ee3d44f76 | ||
|
|
462e554107 | ||
|
|
66afa73e6f | ||
|
|
54c604bcf4 | ||
|
|
c1be02ba54 | ||
|
|
d3c7ba8f45 | ||
|
|
039c4a0496 | ||
|
|
51a94b6bbc | ||
|
|
bbfbb94f52 | ||
|
|
d1eb9ef16f | ||
|
|
3db00f8bc3 |
56
.github/CODEOWNERS
vendored
56
.github/CODEOWNERS
vendored
@@ -52,49 +52,49 @@ go.mod @therealpandey
|
||||
|
||||
# Querier Owners
|
||||
|
||||
/pkg/querier/ @srikanthccv
|
||||
/pkg/variables/ @srikanthccv
|
||||
/pkg/types/querybuildertypes/ @srikanthccv
|
||||
/pkg/types/telemetrytypes/ @srikanthccv
|
||||
/pkg/querybuilder/ @srikanthccv
|
||||
/pkg/telemetrylogs/ @srikanthccv
|
||||
/pkg/telemetrymetadata/ @srikanthccv
|
||||
/pkg/telemetrymetrics/ @srikanthccv
|
||||
/pkg/telemetrytraces/ @srikanthccv
|
||||
/pkg/querier/ @srikanthccv @therealpandey
|
||||
/pkg/variables/ @srikanthccv @therealpandey
|
||||
/pkg/types/querybuildertypes/ @srikanthccv @therealpandey
|
||||
/pkg/types/telemetrytypes/ @srikanthccv @therealpandey
|
||||
/pkg/querybuilder/ @srikanthccv @therealpandey
|
||||
/pkg/telemetrylogs/ @srikanthccv @therealpandey
|
||||
/pkg/telemetrymetadata/ @srikanthccv @therealpandey
|
||||
/pkg/telemetrymetrics/ @srikanthccv @therealpandey
|
||||
/pkg/telemetrytraces/ @srikanthccv @therealpandey
|
||||
|
||||
# Metrics
|
||||
|
||||
/pkg/types/metrictypes/ @srikanthccv
|
||||
/pkg/types/metricsexplorertypes/ @srikanthccv
|
||||
/pkg/modules/metricsexplorer/ @srikanthccv
|
||||
/pkg/prometheus/ @srikanthccv
|
||||
/pkg/types/metrictypes/ @srikanthccv @therealpandey
|
||||
/pkg/types/metricsexplorertypes/ @srikanthccv @therealpandey
|
||||
/pkg/modules/metricsexplorer/ @srikanthccv @therealpandey
|
||||
/pkg/prometheus/ @srikanthccv @therealpandey
|
||||
|
||||
# APM
|
||||
|
||||
/pkg/types/servicetypes/ @srikanthccv
|
||||
/pkg/types/apdextypes/ @srikanthccv
|
||||
/pkg/modules/apdex/ @srikanthccv
|
||||
/pkg/modules/services/ @srikanthccv
|
||||
/pkg/types/servicetypes/ @srikanthccv @therealpandey
|
||||
/pkg/types/apdextypes/ @srikanthccv @therealpandey
|
||||
/pkg/modules/apdex/ @srikanthccv @therealpandey
|
||||
/pkg/modules/services/ @srikanthccv @therealpandey
|
||||
|
||||
# Dashboard
|
||||
|
||||
/pkg/types/dashboardtypes/ @srikanthccv
|
||||
/pkg/modules/dashboard/ @srikanthccv
|
||||
/pkg/types/dashboardtypes/ @srikanthccv @therealpandey
|
||||
/pkg/modules/dashboard/ @srikanthccv @therealpandey
|
||||
|
||||
# Rule/Alertmanager
|
||||
|
||||
/pkg/types/ruletypes/ @srikanthccv
|
||||
/pkg/types/alertmanagertypes @srikanthccv
|
||||
/pkg/alertmanager/ @srikanthccv
|
||||
/pkg/ruler/ @srikanthccv
|
||||
/pkg/modules/rulestatehistory/ @srikanthccv
|
||||
/pkg/types/rulestatehistorytypes/ @srikanthccv
|
||||
/pkg/types/ruletypes/ @srikanthccv @therealpandey
|
||||
/pkg/types/alertmanagertypes @srikanthccv @therealpandey
|
||||
/pkg/alertmanager/ @srikanthccv @therealpandey
|
||||
/pkg/ruler/ @srikanthccv @therealpandey
|
||||
/pkg/modules/rulestatehistory/ @srikanthccv @therealpandey
|
||||
/pkg/types/rulestatehistorytypes/ @srikanthccv @therealpandey
|
||||
|
||||
# Correlation-adjacent
|
||||
|
||||
/pkg/contextlinks/ @srikanthccv
|
||||
/pkg/types/parsertypes/ @srikanthccv
|
||||
/pkg/queryparser/ @srikanthccv
|
||||
/pkg/contextlinks/ @srikanthccv @therealpandey
|
||||
/pkg/types/parsertypes/ @srikanthccv @therealpandey
|
||||
/pkg/queryparser/ @srikanthccv @therealpandey
|
||||
|
||||
# AuthN / AuthZ Owners
|
||||
|
||||
|
||||
3
.github/workflows/integrationci.yaml
vendored
3
.github/workflows/integrationci.yaml
vendored
@@ -51,6 +51,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
@@ -61,7 +62,7 @@ jobs:
|
||||
- 25.5.6
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.142.0
|
||||
- v0.144.3
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
|
||||
@@ -2474,6 +2474,223 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
failedPodCount:
|
||||
type: integer
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
namespaceCPU:
|
||||
format: double
|
||||
type: number
|
||||
namespaceMemory:
|
||||
format: double
|
||||
type: number
|
||||
namespaceName:
|
||||
type: string
|
||||
pendingPodCount:
|
||||
type: integer
|
||||
runningPodCount:
|
||||
type: integer
|
||||
succeededPodCount:
|
||||
type: integer
|
||||
unknownPodCount:
|
||||
type: integer
|
||||
required:
|
||||
- namespaceName
|
||||
- namespaceCPU
|
||||
- namespaceMemory
|
||||
- pendingPodCount
|
||||
- runningPodCount
|
||||
- succeededPodCount
|
||||
- failedPodCount
|
||||
- unknownPodCount
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNamespaces:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesNodeRecord:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
nodeCPU:
|
||||
format: double
|
||||
type: number
|
||||
nodeCPUAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeMemory:
|
||||
format: double
|
||||
type: number
|
||||
nodeMemoryAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeName:
|
||||
type: string
|
||||
notReadyNodesCount:
|
||||
type: integer
|
||||
readyNodesCount:
|
||||
type: integer
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- readyNodesCount
|
||||
- notReadyNodesCount
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
- nodeMemoryAllocatable
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNodes:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
- running
|
||||
- succeeded
|
||||
- failed
|
||||
- unknown
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesPodRecord:
|
||||
properties:
|
||||
failedPodCount:
|
||||
type: integer
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
pendingPodCount:
|
||||
type: integer
|
||||
podAge:
|
||||
format: int64
|
||||
type: integer
|
||||
podCPU:
|
||||
format: double
|
||||
type: number
|
||||
podCPULimit:
|
||||
format: double
|
||||
type: number
|
||||
podCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
podMemoryLimit:
|
||||
format: double
|
||||
type: number
|
||||
podMemoryRequest:
|
||||
format: double
|
||||
type: number
|
||||
podPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podUID:
|
||||
type: string
|
||||
runningPodCount:
|
||||
type: integer
|
||||
succeededPodCount:
|
||||
type: integer
|
||||
unknownPodCount:
|
||||
type: integer
|
||||
required:
|
||||
- podUID
|
||||
- podCPU
|
||||
- podCPURequest
|
||||
- podCPULimit
|
||||
- podMemory
|
||||
- podMemoryRequest
|
||||
- podMemoryLimit
|
||||
- podPhase
|
||||
- pendingPodCount
|
||||
- runningPodCount
|
||||
- succeededPodCount
|
||||
- failedPodCount
|
||||
- unknownPodCount
|
||||
- podAge
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesPods:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPostableHosts:
|
||||
properties:
|
||||
end:
|
||||
@@ -2500,6 +2717,84 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableNamespaces:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableNodes:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostablePods:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesRequiredMetricsCheck:
|
||||
properties:
|
||||
missingMetrics:
|
||||
@@ -10886,7 +11181,9 @@ paths:
|
||||
five metrics, and pagination via offset/limit. The response type is ''list''
|
||||
for the default host.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys. Also reports missing required metrics and whether the requested time
|
||||
range falls before the data retention boundary.'
|
||||
range falls before the data retention boundary. Numeric metric fields (cpu,
|
||||
memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
operationId: ListHosts
|
||||
requestBody:
|
||||
content:
|
||||
@@ -10940,6 +11237,218 @@ paths:
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/namespaces:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes namespaces with key aggregated
|
||||
pod metrics: CPU usage and memory working set (summed across pods in the group),
|
||||
plus per-group pod counts bucketed by each pod''s latest k8s.pod.phase value
|
||||
in the window (pendingPodCount, runningPodCount, succeededPodCount, failedPodCount,
|
||||
unknownPodCount). Each namespace includes metadata attributes (k8s.namespace.name,
|
||||
k8s.cluster.name). The response type is ''list'' for the default k8s.namespace.name
|
||||
grouping or ''grouped_list'' for custom groupBy keys; in both modes every
|
||||
row aggregates pods in the group. Supports filtering via a filter expression,
|
||||
custom groupBy, ordering by cpu / memory, and pagination via offset/limit.
|
||||
Also reports missing required metrics and whether the requested time range
|
||||
falls before the data retention boundary. Numeric metric fields (namespaceCPU,
|
||||
namespaceMemory) return -1 as a sentinel when no data is available for that
|
||||
field.'
|
||||
operationId: ListNamespaces
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableNamespaces'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNamespaces'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Namespaces for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/nodes:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
|
||||
CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group
|
||||
readyNodesCount / notReadyNodesCount derived from each node''s latest k8s.node.condition_ready
|
||||
value in the window. Each node includes metadata attributes (k8s.node.uid,
|
||||
k8s.cluster.name). The response type is ''list'' for the default k8s.node.name
|
||||
grouping (each row is one node with its current condition string: ready /
|
||||
not_ready / '''') or ''grouped_list'' for custom groupBy keys (each row aggregates
|
||||
nodes in the group with readyNodesCount and notReadyNodesCount; condition
|
||||
stays empty). Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
operationId: ListNodes
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodes'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Nodes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/pods:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes pods with key metrics:
|
||||
CPU usage, CPU request/limit utilization, memory working set, memory request/limit
|
||||
utilization, current pod phase (pending/running/succeeded/failed/unknown),
|
||||
and pod age (ms since start time). Each pod includes metadata attributes (namespace,
|
||||
node, workload owner such as deployment/statefulset/daemonset/job/cronjob,
|
||||
cluster). Supports filtering via a filter expression, custom groupBy to aggregate
|
||||
pods by any attribute, ordering by any of the six metrics (cpu, cpu_request,
|
||||
cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit.
|
||||
The response type is ''list'' for the default k8s.pod.uid grouping (each row
|
||||
is one pod with its current phase) or ''grouped_list'' for custom groupBy
|
||||
keys (each row aggregates pods in the group with per-phase counts: pendingPodCount,
|
||||
runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived
|
||||
from each pod''s latest phase in the window). Also reports missing required
|
||||
metrics and whether the requested time range falls before the data retention
|
||||
boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory,
|
||||
podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no
|
||||
data is available for that field.'
|
||||
operationId: ListPods
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostablePods'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPods'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List Pods for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/livez:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
description: Prefer SigNoz UI and icons across frontend code
|
||||
globs: **/*.{ts,tsx,js,jsx}
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# UI Components and Icons Source of Truth
|
||||
|
||||
For all frontend implementation work in this repository:
|
||||
|
||||
- Always use UI primitives/components from `@signozhq/ui`.
|
||||
- Always use icons from `@signozhq/icons`.
|
||||
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
|
||||
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
|
||||
|
||||
## Migration guidance
|
||||
|
||||
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
|
||||
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.
|
||||
|
||||
5
frontend/knip.json
Normal file
5
frontend/knip.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts"]
|
||||
}
|
||||
39763
frontend/package-lock.json
generated
39763
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,6 @@
|
||||
"react": "18.2.0",
|
||||
"react-addons-update": "15.6.3",
|
||||
"react-beautiful-dnd": "13.1.1",
|
||||
"react-chartjs-2": "4",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -134,7 +133,6 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rollup-plugin-visualizer": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
@@ -214,9 +212,9 @@
|
||||
"msw": "1.3.2",
|
||||
"npm-run-all": "latest",
|
||||
"orval": "7.18.0",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "0.21.1",
|
||||
"oxfmt": "0.47.0",
|
||||
"oxlint": "1.62.0",
|
||||
"oxlint-tsgolint": "0.22.1",
|
||||
"portfinder-sync": "^0.0.2",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-scss": "4.0.9",
|
||||
@@ -240,9 +238,12 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.(js|jsx|ts|tsx)": [
|
||||
"oxfmt --check",
|
||||
"oxlint --quiet",
|
||||
"oxlint --fix",
|
||||
"oxfmt --write",
|
||||
"sh scripts/typecheck-staged.sh"
|
||||
],
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -265,4 +266,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# SigNoz AI Assistant
|
||||
|
||||
1. Chat interface (Side Drawer View)
|
||||
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
|
||||
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
|
||||
2. Input Section
|
||||
1. Users should be able to upload images / files to the chat
|
||||
|
||||
|
||||
@@ -99,13 +99,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const isAIAssistantEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.AI_ASSISTANT_ENABLED)
|
||||
?.active ?? false;
|
||||
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
|
||||
@@ -212,30 +212,6 @@ function App(): JSX.Element {
|
||||
activeLicenseFetchError,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedInState || isFetchingFeatureFlags) {
|
||||
return;
|
||||
}
|
||||
const isAIAssistantEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.AI_ASSISTANT_ENABLED)
|
||||
?.active ?? false;
|
||||
|
||||
setRoutes((prev) => {
|
||||
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
if (isAIAssistantEnabled === hasAi) {
|
||||
return prev;
|
||||
}
|
||||
if (isAIAssistantEnabled) {
|
||||
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
if (!aiRoute) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
|
||||
}
|
||||
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
|
||||
});
|
||||
}, [isLoggedInState, isFetchingFeatureFlags, featureFlags]);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -245,8 +221,7 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
} else {
|
||||
|
||||
@@ -317,10 +317,3 @@ export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
export const AIAssistantPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
@@ -497,13 +496,6 @@ const routes: AppRoutes[] = [
|
||||
key: 'API_MONITORING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
/**
|
||||
* AI Assistant API client.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /api/v1/assistant/threads → { threadId }
|
||||
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
|
||||
*
|
||||
* For subsequent messages in the same thread, repeat steps 2–3.
|
||||
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
|
||||
* to resume, which each return a new executionId to open a fresh SSE stream.
|
||||
*/
|
||||
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// Direct URL to the AI backend — set VITE_AI_BACKEND_URL in .env (see vite.config `define`).
|
||||
const AI_BACKEND = process.env.VITE_AI_BACKEND_URL || 'http://localhost:8001';
|
||||
const BASE = `${AI_BACKEND}/api/v1/assistant`;
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SSEEvent =
|
||||
| {
|
||||
type: 'status';
|
||||
executionId: string;
|
||||
state: ExecutionState;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'message';
|
||||
executionId: string;
|
||||
messageId: string;
|
||||
delta: string;
|
||||
done: boolean;
|
||||
actions: unknown[] | null;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'thinking';
|
||||
executionId: string;
|
||||
content: string;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_call';
|
||||
executionId: string;
|
||||
messageId: string | null;
|
||||
toolCallId: string | null;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
executionId: string;
|
||||
messageId: string | null;
|
||||
toolCallId: string | null;
|
||||
success: boolean;
|
||||
toolName: string;
|
||||
result: Record<string, unknown>;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'approval';
|
||||
executionId: string;
|
||||
approvalId: string;
|
||||
actionType: string;
|
||||
resourceType: string;
|
||||
summary: string;
|
||||
diff: { before: unknown; after: unknown } | null;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'clarification';
|
||||
executionId: string;
|
||||
clarificationId: string;
|
||||
message: string;
|
||||
discoveredContext: Record<string, unknown> | null;
|
||||
fields: ClarificationFieldRaw[];
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
executionId: string;
|
||||
error: { type: string; code: string; message: string; details: unknown };
|
||||
retryAction: 'auto' | 'manual' | 'none';
|
||||
eventId: number;
|
||||
}
|
||||
| { type: 'conversation'; threadId: string; title: string; eventId: number }
|
||||
| {
|
||||
type: 'done';
|
||||
executionId: string;
|
||||
tokenInput: number;
|
||||
tokenOutput: number;
|
||||
latencyMs: number;
|
||||
toolCallCount?: number;
|
||||
retryCount?: number;
|
||||
eventId: number;
|
||||
};
|
||||
|
||||
export interface ClarificationFieldRaw {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[] | null;
|
||||
default?: string | string[] | null;
|
||||
}
|
||||
|
||||
export type ExecutionState =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'awaiting_approval'
|
||||
| 'awaiting_clarification'
|
||||
| 'resumed'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'canceled';
|
||||
|
||||
export interface ApprovalSummary {
|
||||
approvalId: string;
|
||||
executionId: string;
|
||||
sourceMessageId: string;
|
||||
state: string;
|
||||
actionType: string;
|
||||
resourceType: string;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ClarificationSummary {
|
||||
clarificationId: string;
|
||||
executionId: string;
|
||||
sourceMessageId: string;
|
||||
state: string;
|
||||
message: string;
|
||||
discoveredContext: Record<string, unknown> | null;
|
||||
fields: ClarificationFieldRaw[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — Create thread
|
||||
// POST /api/v1/assistant/threads → { threadId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread listing & detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ThreadSummary {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
state: string | null;
|
||||
activeExecutionId: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponse {
|
||||
threads: ThreadSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ListThreadsOptions {
|
||||
archived?: 'true' | 'false' | 'all';
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
sort?: 'updated_desc';
|
||||
}
|
||||
|
||||
export interface MessageSummaryBlock {
|
||||
type: string;
|
||||
content?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
result?: unknown;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageContext {
|
||||
source: 'auto' | 'mention';
|
||||
type:
|
||||
| 'dashboard'
|
||||
| 'alert'
|
||||
| 'saved_view'
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'metrics_explorer'
|
||||
| 'service';
|
||||
resourceId?: string | null;
|
||||
resourceName?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
messageId: string;
|
||||
role: string;
|
||||
contentType: string;
|
||||
content: string | null;
|
||||
complete: boolean;
|
||||
toolCalls: Record<string, unknown>[] | null;
|
||||
blocks: MessageSummaryBlock[] | null;
|
||||
actions: unknown[] | null;
|
||||
feedbackRating: 'positive' | 'negative' | null;
|
||||
feedbackComment: string | null;
|
||||
executionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadDetailResponse {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
state: string | null;
|
||||
activeExecutionId: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: MessageSummary[];
|
||||
pendingApproval: ApprovalSummary | null;
|
||||
pendingClarification: ClarificationSummary | null;
|
||||
}
|
||||
|
||||
export async function listThreads(
|
||||
options: ListThreadsOptions = {},
|
||||
): Promise<ThreadListResponse> {
|
||||
const {
|
||||
archived = 'false',
|
||||
limit = 20,
|
||||
cursor = null,
|
||||
sort = 'updated_desc',
|
||||
} = options;
|
||||
const params = new URLSearchParams({
|
||||
archived,
|
||||
limit: String(limit),
|
||||
sort,
|
||||
});
|
||||
if (cursor) {
|
||||
params.set('cursor', cursor);
|
||||
}
|
||||
const res = await fetch(`${BASE}/threads?${params.toString()}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to list threads: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateThread(
|
||||
threadId: string,
|
||||
update: { title?: string | null; archived?: boolean | null },
|
||||
): Promise<ThreadSummary> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to update thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getThreadDetail(
|
||||
threadId: string,
|
||||
): Promise<ThreadDetailResponse> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to get thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createThread(signal?: AbortSignal): Promise<string> {
|
||||
const res = await fetch(`${BASE}/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({}),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to create thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { threadId: string } = await res.json();
|
||||
return data.threadId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2 — Send message
|
||||
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
|
||||
async function getActiveExecutionId(threadId: string): Promise<string | null> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const data: { activeExecutionId?: string | null } = await res.json();
|
||||
return data.activeExecutionId ?? null;
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
contexts?: MessageContext[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
...(contexts && contexts.length > 0 ? { contexts } : {}),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
// Thread has an active execution — reconnect to it instead of failing.
|
||||
const executionId = await getActiveExecutionId(threadId);
|
||||
if (executionId) {
|
||||
return executionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to send message: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3 — Stream execution events
|
||||
// GET /api/v1/assistant/executions/{executionId}/events → SSE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSSELine(line: string): SSEEvent | null {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return null;
|
||||
}
|
||||
const json = line.slice('data: '.length).trim();
|
||||
if (!json || json === '[DONE]') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(json) as SSEEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSSEChunk(chunk: string): SSEEvent[] {
|
||||
return chunk
|
||||
.split('\n\n')
|
||||
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
|
||||
.map(parseSSELine)
|
||||
.filter((e): e is SSEEvent => e !== null);
|
||||
}
|
||||
|
||||
async function* readSSEReader(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const decoder = new TextDecoder();
|
||||
let lineBuffer = '';
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
lineBuffer += decoder.decode(value, { stream: true });
|
||||
const parts = lineBuffer.split('\n\n');
|
||||
lineBuffer = parts.pop() ?? '';
|
||||
yield* parts.flatMap(parseSSEChunk);
|
||||
}
|
||||
yield* parseSSEChunk(lineBuffer);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamEvents(
|
||||
executionId: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const res = await fetch(`${BASE}/executions/${executionId}/events`, {
|
||||
headers: { ...authHeaders() },
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`SSE stream failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
yield* readSSEReader(res.body.getReader());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval / Clarification / Cancel actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function approveExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ approvalId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to approve: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
/** Reject a pending action. */
|
||||
export async function rejectExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ approvalId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to reject: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function clarifyExecution(
|
||||
clarificationId: string,
|
||||
answers: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/clarify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ clarificationId, answers }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to clarify: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
/** Cancel the active execution on a thread. */
|
||||
export interface CancelResponse {
|
||||
executionId: string;
|
||||
previousState: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function cancelExecution(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CancelResponse> {
|
||||
const res = await fetch(`${BASE}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ threadId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to cancel: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FeedbackRating = 'positive' | 'negative';
|
||||
|
||||
export async function submitFeedback(
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
comment?: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/messages/${messageId}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ rating, comment: comment ?? null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to submit feedback: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ describe('getFieldKeys API', () => {
|
||||
const result = await getFieldKeys('traces');
|
||||
|
||||
// Verify the returned structure matches SuccessResponseV2 format
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
httpStatusCode: 200,
|
||||
data: mockSuccessResponse.data.data,
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('getFieldValues API', () => {
|
||||
const result = await getFieldValues('traces', 'service.name');
|
||||
|
||||
// Verify the returned structure matches SuccessResponseV2 format
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
httpStatusCode: 200,
|
||||
data: expect.objectContaining({
|
||||
values: expect.any(Object),
|
||||
|
||||
@@ -13,7 +13,13 @@ import type {
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostableNamespacesDTO,
|
||||
InframonitoringtypesPostableNodesDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
ListHosts200,
|
||||
ListNamespaces200,
|
||||
ListNodes200,
|
||||
ListPods200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -21,7 +27,7 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.
|
||||
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Hosts for Infra Monitoring
|
||||
*/
|
||||
export const listHosts = (
|
||||
@@ -104,3 +110,255 @@ export const useListHosts = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group pod counts bucketed by each pod's latest k8s.pod.phase value in the window (pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Namespaces for Infra Monitoring
|
||||
*/
|
||||
export const listNamespaces = (
|
||||
inframonitoringtypesPostableNamespacesDTO: BodyType<InframonitoringtypesPostableNamespacesDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNamespaces200>({
|
||||
url: `/api/v2/infra_monitoring/namespaces`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableNamespacesDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNamespacesMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNamespaces>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNamespacesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNamespaces>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNamespacesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listNamespaces'];
|
||||
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 listNamespaces>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableNamespacesDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listNamespaces(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListNamespacesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNamespaces>>
|
||||
>;
|
||||
export type ListNamespacesMutationBody =
|
||||
BodyType<InframonitoringtypesPostableNamespacesDTO>;
|
||||
export type ListNamespacesMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Namespaces for Infra Monitoring
|
||||
*/
|
||||
export const useListNamespaces = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNamespaces>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNamespacesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listNamespaces>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNamespacesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListNamespacesMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group readyNodesCount / notReadyNodesCount derived from each node's latest k8s.node.condition_ready value in the window. Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / '') or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group with readyNodesCount and notReadyNodesCount; condition stays empty). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const listNodes = (
|
||||
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNodes200>({
|
||||
url: `/api/v2/infra_monitoring/nodes`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableNodesDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNodesMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listNodes'];
|
||||
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 listNodes>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listNodes(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListNodesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNodes>>
|
||||
>;
|
||||
export type ListNodesMutationBody =
|
||||
BodyType<InframonitoringtypesPostableNodesDTO>;
|
||||
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const useListNodes = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListNodesMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const listPods = (
|
||||
inframonitoringtypesPostablePodsDTO: BodyType<InframonitoringtypesPostablePodsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListPods200>({
|
||||
url: `/api/v2/infra_monitoring/pods`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostablePodsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPodsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listPods'];
|
||||
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 listPods>>,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listPods(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListPodsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPods>>
|
||||
>;
|
||||
export type ListPodsMutationBody =
|
||||
BodyType<InframonitoringtypesPostablePodsDTO>;
|
||||
export type ListPodsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Pods for Infra Monitoring
|
||||
*/
|
||||
export const useListPods = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listPods>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostablePodsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListPodsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
@@ -3243,6 +3243,248 @@ export interface InframonitoringtypesHostsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesNamespaceRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesNamespaceRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failedPodCount: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesNamespaceRecordDTOMeta;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
namespaceCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
namespaceMemory: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
namespaceName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pendingPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
runningPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeededPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknownPodCount: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNamespacesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesNamespaceRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesNodeConditionDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
'' = '',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesNodeRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesNodeRecordDTO {
|
||||
condition: InframonitoringtypesNodeConditionDTO;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesNodeRecordDTOMeta;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPUAllocatable: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemoryAllocatable: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nodeName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
notReadyNodesCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
readyNodesCount: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesPodPhaseDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
succeeded = 'succeeded',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
'' = '',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesPodRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failedPodCount: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesPodRecordDTOMeta;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
pendingPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
podAge: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podCPULimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podCPURequest: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podMemoryLimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
podMemoryRequest: number;
|
||||
podPhase: InframonitoringtypesPodPhaseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
podUID: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
runningPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeededPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknownPodCount: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesPodRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableHostsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3271,6 +3513,90 @@ export interface InframonitoringtypesPostableHostsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableNamespacesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableNodesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostablePodsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesRequiredMetricsCheckDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -7350,6 +7676,30 @@ export type ListHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNamespaces200 = {
|
||||
data: InframonitoringtypesNamespacesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNodes200 = {
|
||||
data: InframonitoringtypesNodesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Livez200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -76,10 +76,10 @@ describe('interceptorRejected', () => {
|
||||
}
|
||||
|
||||
const mockAxiosFn = axios as unknown as jest.Mock;
|
||||
expect(mockAxiosFn.mock.calls.length).toBe(1);
|
||||
expect(mockAxiosFn.mock.calls).toHaveLength(1);
|
||||
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
|
||||
expect(Array.isArray(JSON.parse(retryCallConfig.data))).toBe(true);
|
||||
expect(JSON.parse(retryCallConfig.data)).toEqual(arrayPayload);
|
||||
expect(JSON.parse(retryCallConfig.data)).toStrictEqual(arrayPayload);
|
||||
});
|
||||
|
||||
it('should preserve object payload structure when retrying a 401 request', async () => {
|
||||
@@ -112,9 +112,9 @@ describe('interceptorRejected', () => {
|
||||
}
|
||||
|
||||
const mockAxiosFn = axios as unknown as jest.Mock;
|
||||
expect(mockAxiosFn.mock.calls.length).toBe(1);
|
||||
expect(mockAxiosFn.mock.calls).toHaveLength(1);
|
||||
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
|
||||
expect(JSON.parse(retryCallConfig.data)).toEqual(objectPayload);
|
||||
expect(JSON.parse(retryCallConfig.data)).toStrictEqual(objectPayload);
|
||||
});
|
||||
|
||||
it('should handle undefined data gracefully when retrying', async () => {
|
||||
@@ -145,7 +145,7 @@ describe('interceptorRejected', () => {
|
||||
}
|
||||
|
||||
const mockAxiosFn = axios as unknown as jest.Mock;
|
||||
expect(mockAxiosFn.mock.calls.length).toBe(1);
|
||||
expect(mockAxiosFn.mock.calls).toHaveLength(1);
|
||||
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
|
||||
expect(retryCallConfig.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
const q = result.payload.data.result[0];
|
||||
expect(q.queryName).toBe('A');
|
||||
expect(q.legend).toBe('{{service.name}}');
|
||||
expect(q.series?.[0]).toEqual(
|
||||
expect(q.series?.[0]).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
labels: { 'service.name': 'adservice' },
|
||||
values: [
|
||||
@@ -186,7 +186,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
|
||||
expect(result.payload.data.resultType).toBe('scalar');
|
||||
const [tableEntry] = result.payload.data.result;
|
||||
expect(tableEntry.table?.columns).toEqual([
|
||||
expect(tableEntry.table?.columns).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
@@ -202,7 +202,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
},
|
||||
{ name: 'F1', queryName: 'F1', isValueColumn: true, id: 'F1' },
|
||||
]);
|
||||
expect(tableEntry.table?.rows?.[0]).toEqual({
|
||||
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
|
||||
data: {
|
||||
'service.name': 'adservice',
|
||||
'A.count()': 606,
|
||||
@@ -257,7 +257,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
|
||||
expect(result.payload.data.resultType).toBe('scalar');
|
||||
const [tableEntry] = result.payload.data.result;
|
||||
expect(tableEntry.table?.columns).toEqual([
|
||||
expect(tableEntry.table?.columns).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
queryName: 'A',
|
||||
@@ -267,7 +267,7 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
// Single aggregation: name resolves to legend, id resolves to queryName
|
||||
{ name: '{{service.name}}', queryName: 'A', isValueColumn: true, id: 'A' },
|
||||
]);
|
||||
expect(tableEntry.table?.rows?.[0]).toEqual({
|
||||
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
|
||||
data: {
|
||||
'service.name': 'adservice',
|
||||
A: 580,
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: { A: 'Legend A', F1: 'Formula Legend' },
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -154,7 +154,10 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
);
|
||||
|
||||
// Legend map combines builder and formulas
|
||||
expect(result.legendMap).toEqual({ A: 'Legend A', F1: 'Formula Legend' });
|
||||
expect(result.legendMap).toStrictEqual({
|
||||
A: 'Legend A',
|
||||
F1: 'Formula Legend',
|
||||
});
|
||||
|
||||
const payload: QueryRangePayloadV5 = result.queryPayload;
|
||||
|
||||
@@ -166,10 +169,10 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
expect(payload.formatOptions?.fillGaps).toBe(true);
|
||||
|
||||
// Variables mapped as { key: { value } }
|
||||
expect(payload.variables).toEqual({
|
||||
svc: { value: 'api' },
|
||||
count: { value: 5 },
|
||||
flag: { value: true },
|
||||
expect(payload.variables).toStrictEqual({
|
||||
svc: { value: 'api', type: undefined },
|
||||
count: { value: 5, type: undefined },
|
||||
flag: { value: true, type: undefined },
|
||||
});
|
||||
|
||||
// Queries include one builder_query and one builder_formula
|
||||
@@ -226,7 +229,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: { A: 'LP' },
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -255,7 +258,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.legendMap).toEqual({ A: 'LP' });
|
||||
expect(result.legendMap).toStrictEqual({ A: 'LP' });
|
||||
|
||||
const payload: QueryRangePayloadV5 = result.queryPayload;
|
||||
expect(payload.requestType).toBe('time_series');
|
||||
@@ -296,7 +299,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: { Q: 'LC' },
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -324,7 +327,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.legendMap).toEqual({ Q: 'LC' });
|
||||
expect(result.legendMap).toStrictEqual({ Q: 'LC' });
|
||||
|
||||
const payload: QueryRangePayloadV5 = result.queryPayload;
|
||||
expect(payload.requestType).toBe('scalar');
|
||||
@@ -353,7 +356,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: {},
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -397,7 +400,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: { A: 'Legend A' },
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -471,7 +474,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: { A: 'Legend A' },
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -585,7 +588,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect(result).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
legendMap: { A: '{{service.name}}' },
|
||||
queryPayload: expect.objectContaining({
|
||||
@@ -684,7 +687,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
const result = prepareQueryRangePayloadV5(props);
|
||||
|
||||
expect(result.legendMap).toEqual({ A: 'Legend A' });
|
||||
expect(result.legendMap).toStrictEqual({ A: 'Legend A' });
|
||||
expect(result.queryPayload.compositeQuery.queries).toHaveLength(1);
|
||||
|
||||
const builderQuery = result.queryPayload.compositeQuery.queries.find(
|
||||
@@ -694,7 +697,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
|
||||
expect(logSpec.name).toBe('A');
|
||||
expect(logSpec.signal).toBe('logs');
|
||||
expect(logSpec.filter).toEqual({
|
||||
expect(logSpec.filter).toStrictEqual({
|
||||
expression:
|
||||
"service.name = 'payment-service' AND http.status_code >= 400 AND message contains 'error'",
|
||||
});
|
||||
@@ -731,7 +734,9 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
(q) => q.type === 'builder_query',
|
||||
) as QueryEnvelope;
|
||||
const logSpec = builderQuery.spec as LogBuilderQuery;
|
||||
expect(logSpec.filter).toEqual({ expression: 'http.status_code >= 500' });
|
||||
expect(logSpec.filter).toStrictEqual({
|
||||
expression: 'http.status_code >= 500',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives expression from filters when filter is undefined', () => {
|
||||
@@ -775,7 +780,9 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
(q) => q.type === 'builder_query',
|
||||
) as QueryEnvelope;
|
||||
const logSpec = builderQuery.spec as LogBuilderQuery;
|
||||
expect(logSpec.filter).toEqual({ expression: "service.name = 'checkout'" });
|
||||
expect(logSpec.filter).toStrictEqual({
|
||||
expression: "service.name = 'checkout'",
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers filter.expression over filters when both are present', () => {
|
||||
@@ -819,7 +826,9 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
(q) => q.type === 'builder_query',
|
||||
) as QueryEnvelope;
|
||||
const logSpec = builderQuery.spec as LogBuilderQuery;
|
||||
expect(logSpec.filter).toEqual({ expression: "service.name = 'frontend'" });
|
||||
expect(logSpec.filter).toStrictEqual({
|
||||
expression: "service.name = 'frontend'",
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty expression when neither filter nor filters provided', () => {
|
||||
@@ -853,7 +862,7 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
(q) => q.type === 'builder_query',
|
||||
) as QueryEnvelope;
|
||||
const logSpec = builderQuery.spec as LogBuilderQuery;
|
||||
expect(logSpec.filter).toEqual({ expression: '' });
|
||||
expect(logSpec.filter).toStrictEqual({ expression: '' });
|
||||
});
|
||||
|
||||
it('returns empty expression when filters provided with empty items', () => {
|
||||
@@ -887,6 +896,6 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
(q) => q.type === 'builder_query',
|
||||
) as QueryEnvelope;
|
||||
const logSpec = builderQuery.spec as LogBuilderQuery;
|
||||
expect(logSpec.filter).toEqual({ expression: '' });
|
||||
expect(logSpec.filter).toStrictEqual({ expression: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ describe.each([
|
||||
const callArgs = mockDownloadExportData.mock.calls[0][0];
|
||||
const query = callArgs.body.compositeQuery.queries[0];
|
||||
expect(query.spec.groupBy).toBeUndefined();
|
||||
expect(query.spec.having).toEqual({ expression: '' });
|
||||
expect(query.spec.having).toStrictEqual({ expression: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,7 +238,7 @@ describe.each([
|
||||
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockDownloadExportData.mock.calls[0][0];
|
||||
const query = callArgs.body.compositeQuery.queries[0];
|
||||
expect(query.spec.selectFields).toEqual([
|
||||
expect(query.spec.selectFields).toStrictEqual([
|
||||
expect.objectContaining({
|
||||
name: 'http.status',
|
||||
fieldDataType: 'int64',
|
||||
|
||||
@@ -6,39 +6,39 @@ jest.mock('react-dnd', () => ({
|
||||
}));
|
||||
|
||||
describe('Utils testing of DraggableTableRow component', () => {
|
||||
test('Should dropHandler return true', () => {
|
||||
it('Should dropHandler return true', () => {
|
||||
const monitor = {
|
||||
isOver: jest.fn().mockReturnValueOnce(true),
|
||||
} as never;
|
||||
const dropDataTruthy = dropHandler(monitor);
|
||||
|
||||
expect(dropDataTruthy).toEqual({ isOver: true });
|
||||
expect(dropDataTruthy).toStrictEqual({ isOver: true });
|
||||
});
|
||||
|
||||
test('Should dropHandler return false', () => {
|
||||
it('Should dropHandler return false', () => {
|
||||
const monitor = {
|
||||
isOver: jest.fn().mockReturnValueOnce(false),
|
||||
} as never;
|
||||
const dropDataFalsy = dropHandler(monitor);
|
||||
|
||||
expect(dropDataFalsy).toEqual({ isOver: false });
|
||||
expect(dropDataFalsy).toStrictEqual({ isOver: false });
|
||||
});
|
||||
|
||||
test('Should dragHandler return true', () => {
|
||||
it('Should dragHandler return true', () => {
|
||||
const monitor = {
|
||||
isDragging: jest.fn().mockReturnValueOnce(true),
|
||||
} as never;
|
||||
const dragDataTruthy = dragHandler(monitor);
|
||||
|
||||
expect(dragDataTruthy).toEqual({ isDragging: true });
|
||||
expect(dragDataTruthy).toStrictEqual({ isDragging: true });
|
||||
});
|
||||
|
||||
test('Should dragHandler return false', () => {
|
||||
it('Should dragHandler return false', () => {
|
||||
const monitor = {
|
||||
isDragging: jest.fn().mockReturnValueOnce(false),
|
||||
} as never;
|
||||
const dragDataFalsy = dragHandler(monitor);
|
||||
|
||||
expect(dragDataFalsy).toEqual({ isDragging: false });
|
||||
expect(dragDataFalsy).toStrictEqual({ isDragging: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,9 +361,9 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete member/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to delete/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/are you sure you want to delete/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /delete member/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
@@ -441,9 +441,9 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /revoke invite/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Are you sure you want to revoke the invite/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Are you sure you want to revoke the invite/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /revoke invite/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
@@ -64,7 +64,7 @@ export const legend = (id: string, isLonger: boolean): Plugin<ChartType> => ({
|
||||
// li.style.marginTop = '5px';
|
||||
|
||||
li.onclick = (): void => {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const { type } = chart.config;
|
||||
if (type === 'pie' || type === 'doughnut') {
|
||||
// Pie and doughnut charts only have a single dataset and visibility is per item
|
||||
|
||||
@@ -9,65 +9,65 @@ describe('xAxisConfig for Chart', () => {
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'millisecond');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.millisecond,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.millisecond);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'second');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.second,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.second);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'minute');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.minute,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.minute);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'hour');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.hour,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.hour);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'day');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.day,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.day);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'week');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.week,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.week);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'month');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.month,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.month);
|
||||
}
|
||||
{
|
||||
const start = dayjs();
|
||||
const end = start.add(10, 'year');
|
||||
|
||||
expect(convertTimeRange(start.valueOf(), end.valueOf()).unitName).toEqual(
|
||||
TIME_UNITS.year,
|
||||
);
|
||||
expect(
|
||||
convertTimeRange(start.valueOf(), end.valueOf()).unitName,
|
||||
).toStrictEqual(TIME_UNITS.year);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ const testFullPrecisionGetYAxisFormattedValue = (
|
||||
): string => getYAxisFormattedValue(value, format, PrecisionOptionsEnum.FULL);
|
||||
|
||||
describe('getYAxisFormattedValue - none (full precision legacy assertions)', () => {
|
||||
test('large integers and decimals', () => {
|
||||
it('large integers and decimals', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('250034', 'none')).toBe(
|
||||
'250034',
|
||||
);
|
||||
@@ -22,7 +22,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves leading zeros after decimal until first non-zero', () => {
|
||||
it('preserves leading zeros after decimal until first non-zero', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.0000234', 'none')).toBe(
|
||||
'1.0000234',
|
||||
);
|
||||
@@ -31,7 +31,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('trims to three significant decimals and removes trailing zeros', () => {
|
||||
it('trims to three significant decimals and removes trailing zeros', () => {
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'none'),
|
||||
).toBe('0.000000250034');
|
||||
@@ -55,7 +55,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
).toBe('0.00000025');
|
||||
});
|
||||
|
||||
test('whole numbers normalize', () => {
|
||||
it('whole numbers normalize', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'none')).toBe('1000');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.5458', 'none')).toBe(
|
||||
'99.5458',
|
||||
@@ -68,7 +68,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('strip redundant decimal zeros', () => {
|
||||
it('strip redundant decimal zeros', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000.000', 'none')).toBe(
|
||||
'1000',
|
||||
);
|
||||
@@ -78,7 +78,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.000', 'none')).toBe('1');
|
||||
});
|
||||
|
||||
test('edge values', () => {
|
||||
it('edge values', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'none')).toBe('0');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-0', 'none')).toBe('0');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'none')).toBe('∞');
|
||||
@@ -92,7 +92,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('abc123', 'none')).toBe('NaN');
|
||||
});
|
||||
|
||||
test('small decimals keep precision as-is', () => {
|
||||
it('small decimals keep precision as-is', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'none')).toBe(
|
||||
'0.0001',
|
||||
);
|
||||
@@ -104,7 +104,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('simple decimals preserved', () => {
|
||||
it('simple decimals preserved', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.1', 'none')).toBe('0.1');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.2', 'none')).toBe('0.2');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.3', 'none')).toBe('0.3');
|
||||
@@ -115,7 +115,7 @@ describe('getYAxisFormattedValue - none (full precision legacy assertions)', ()
|
||||
});
|
||||
|
||||
describe('getYAxisFormattedValue - units (full precision legacy assertions)', () => {
|
||||
test('ms', () => {
|
||||
it('ms', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'ms')).toBe('1.5 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('500', 'ms')).toBe('500 ms');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('60000', 'ms')).toBe('1 min');
|
||||
@@ -127,19 +127,19 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('s', () => {
|
||||
it('s', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('90', 's')).toBe('1.5 mins');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('30', 's')).toBe('30 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('3600', 's')).toBe('1 hour');
|
||||
});
|
||||
|
||||
test('m', () => {
|
||||
it('m', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('90', 'm')).toBe('1.5 hours');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('30', 'm')).toBe('30 min');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1440', 'm')).toBe('1 day');
|
||||
});
|
||||
|
||||
test('bytes', () => {
|
||||
it('bytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'bytes')).toBe(
|
||||
'1 KiB',
|
||||
);
|
||||
@@ -149,7 +149,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('mbytes', () => {
|
||||
it('mbytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'mbytes')).toBe(
|
||||
'1 GiB',
|
||||
);
|
||||
@@ -161,7 +161,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('kbytes', () => {
|
||||
it('kbytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'kbytes')).toBe(
|
||||
'1 MiB',
|
||||
);
|
||||
@@ -173,7 +173,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('short', () => {
|
||||
it('short', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'short')).toBe('1 K');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'short')).toBe(
|
||||
'1.5 K',
|
||||
@@ -201,7 +201,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('percent', () => {
|
||||
it('percent', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.15', 'percent')).toBe(
|
||||
'0.15%',
|
||||
);
|
||||
@@ -235,7 +235,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
).toBe('1.005555555595959%');
|
||||
});
|
||||
|
||||
test('ratio', () => {
|
||||
it('ratio', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.5', 'ratio')).toBe(
|
||||
'0.5 ratio',
|
||||
);
|
||||
@@ -247,7 +247,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('temperature units', () => {
|
||||
it('temperature units', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('25', 'celsius')).toBe(
|
||||
'25 °C',
|
||||
);
|
||||
@@ -267,13 +267,13 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('ms edge cases', () => {
|
||||
it('ms edge cases', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'ms')).toBe('0 ms');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-1500', 'ms')).toBe('-1.5 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'ms')).toBe('∞');
|
||||
});
|
||||
|
||||
test('bytes edge cases', () => {
|
||||
it('bytes edge cases', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'bytes')).toBe('0 B');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-1024', 'bytes')).toBe(
|
||||
'-1 KiB',
|
||||
@@ -282,7 +282,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
|
||||
});
|
||||
|
||||
describe('getYAxisFormattedValue - precision option tests', () => {
|
||||
test('precision 0 drops decimal part', () => {
|
||||
it('precision 0 drops decimal part', () => {
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 0)).toBe('1');
|
||||
expect(getYAxisFormattedValue('0.9999', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('12345.6789', 'none', 0)).toBe('12345');
|
||||
@@ -294,7 +294,7 @@ describe('getYAxisFormattedValue - precision option tests', () => {
|
||||
// with unit
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 0)).toBe('4 s');
|
||||
});
|
||||
test('precision 1,2,3,4 decimals', () => {
|
||||
it('precision 1,2,3,4 decimals', () => {
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 1)).toBe('1.2');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 2)).toBe('1.23');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 3)).toBe('1.234');
|
||||
@@ -345,7 +345,7 @@ describe('getYAxisFormattedValue - precision option tests', () => {
|
||||
expect(getYAxisFormattedValue('0.123456', 'percent', 4)).toBe('0.1235%'); // approximation
|
||||
});
|
||||
|
||||
test('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => {
|
||||
it('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => {
|
||||
expect(
|
||||
getYAxisFormattedValue(
|
||||
'0.00002625429914148441',
|
||||
|
||||
@@ -4,49 +4,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn__prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn__badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn__pulse-dot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
animation: header-ai-assistant-dot-pulse 1.5s ease-in-out infinite;
|
||||
transform: scale(0.8);
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@keyframes header-ai-assistant-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
.share-modal-content,
|
||||
.feedback-modal-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { Popover } from 'antd';
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
@@ -38,7 +29,6 @@ function HeaderRightSection({
|
||||
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const handleOpenFeedbackModal = useCallback((): void => {
|
||||
logEvent('Feedback: Clicked', {
|
||||
@@ -77,44 +67,9 @@ function HeaderRightSection({
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
const pendingUserInputCount: number = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
const showHeaderPendingBadge =
|
||||
pendingUserInputCount > 0 && !isDrawerOpen && !isModalOpen;
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{isAIAssistantEnabled && !isDrawerOpen && (
|
||||
<div className="header-ai-assistant-btn-container">
|
||||
{showHeaderPendingBadge ? (
|
||||
<span className="header-ai-assistant-btn__badge" aria-hidden>
|
||||
<span className="header-ai-assistant-btn__pulse-dot">
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<Tooltip title="AI Assistant">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={openAIAssistant}
|
||||
aria-label={
|
||||
showHeaderPendingBadge
|
||||
? pendingUserInputCount === 1
|
||||
? 'Open AI Assistant, 1 action needs your response'
|
||||
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
|
||||
: 'Open AI Assistant'
|
||||
}
|
||||
prefix={<AIAssistantIcon />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
@@ -128,11 +83,12 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<SquarePen size={14} />}
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
/>
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -149,9 +105,8 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<Inbox size={14} />}
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
@@ -174,11 +129,12 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<Globe size={14} />}
|
||||
className="share-link-btn periscope-btn ghost"
|
||||
icon={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
/>
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,6 @@ jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -90,11 +90,11 @@ describe('InviteMembersModal', () => {
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email-only message when email is invalid but role is selected', async () => {
|
||||
@@ -112,9 +112,9 @@ describe('InviteMembersModal', () => {
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please enter valid emails for team members'),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role-only message when email is valid but role is missing', async () => {
|
||||
@@ -130,9 +130,9 @@ describe('InviteMembersModal', () => {
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please select roles for team members'),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('getLogIndicatorType', () => {
|
||||
expect(getLogIndicatorType(log)).toBe('TRACE');
|
||||
});
|
||||
|
||||
it('severity_text should be used when severity_number is absent ', () => {
|
||||
it('severity_text should be used when severity_number is absent', () => {
|
||||
const log = {
|
||||
date: '2024-02-29T12:34:46Z',
|
||||
timestamp: 1646115296,
|
||||
|
||||
@@ -75,7 +75,7 @@ function OptionsMenu({
|
||||
};
|
||||
|
||||
const handleSearchValueChange = useDebouncedFn((event): void => {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const value = event?.target?.value || '';
|
||||
|
||||
if (addColumn && addColumn?.onSearch) {
|
||||
|
||||
@@ -50,7 +50,7 @@ function Code({
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
style={a11yDark}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
@@ -115,7 +115,7 @@ function MarkdownRenderer({
|
||||
className={className}
|
||||
rehypePlugins={[rehypeRaw as any]}
|
||||
components={{
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
a: Link,
|
||||
pre: ({ children }) =>
|
||||
Pre({
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 1. CUSTOM VALUES SUPPORT =====
|
||||
describe('Custom Values Support (CS)', () => {
|
||||
test('CS-01: Custom values persist in selected state', async () => {
|
||||
it('CS-01: Custom values persist in selected state', async () => {
|
||||
const { rerender } = renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -87,7 +87,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(screen.getByText('another-custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('CS-02: Partial matches create custom values', async () => {
|
||||
it('CS-02: Partial matches create custom values', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -129,7 +129,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('CS-03: Exact match filtering behavior', async () => {
|
||||
it('CS-03: Exact match filtering behavior', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -176,7 +176,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('CS-04: Search filtering with "end" pattern', async () => {
|
||||
it('CS-04: Search filtering with "end" pattern', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -234,7 +234,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('CS-05: Comma-separated values behavior', async () => {
|
||||
it('CS-05: Comma-separated values behavior', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -281,7 +281,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 2. SEARCH AND FILTERING =====
|
||||
describe('Search and Filtering (SF)', () => {
|
||||
test('SF-01: Selected values pushed to top', async () => {
|
||||
it('SF-01: Selected values pushed to top', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -305,7 +305,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SF-02: Filtering with search text', async () => {
|
||||
it('SF-02: Filtering with search text', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -350,7 +350,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SF-03: Highlighting search matches', async () => {
|
||||
it('SF-03: Highlighting search matches', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -381,7 +381,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SF-04: Search with no results', async () => {
|
||||
it('SF-04: Search with no results', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -424,7 +424,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 3. KEYBOARD NAVIGATION =====
|
||||
describe('Keyboard Navigation (KN)', () => {
|
||||
test('KN-01: Arrow key navigation in dropdown', async () => {
|
||||
it('KN-01: Arrow key navigation in dropdown', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -465,7 +465,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('KN-02: Tab navigation to dropdown', async () => {
|
||||
it('KN-02: Tab navigation to dropdown', async () => {
|
||||
renderWithVirtuoso(
|
||||
<div>
|
||||
<input data-testid="prev-input" />
|
||||
@@ -515,7 +515,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('KN-03: Enter selection in dropdown', async () => {
|
||||
it('KN-03: Enter selection in dropdown', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -540,7 +540,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['frontend'], ['frontend']);
|
||||
});
|
||||
|
||||
test('KN-04: Chip deletion with keyboard', async () => {
|
||||
it('KN-04: Chip deletion with keyboard', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -586,7 +586,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 5. UI/UX BEHAVIORS =====
|
||||
describe('UI/UX Behaviors (UI)', () => {
|
||||
test('UI-01: Loading state does not block interaction', async () => {
|
||||
it('UI-01: Loading state does not block interaction', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -603,7 +603,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-02: Component remains editable in all states', async () => {
|
||||
it('UI-02: Component remains editable in all states', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -634,7 +634,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('UI-03: Toggle/Only labels in dropdown', async () => {
|
||||
it('UI-03: Toggle/Only labels in dropdown', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -656,7 +656,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-04: Should display values with loading info at bottom', async () => {
|
||||
it('UI-04: Should display values with loading info at bottom', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -677,7 +677,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-05: Error state display in footer', async () => {
|
||||
it('UI-05: Error state display in footer', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -696,7 +696,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-06: No data state display', async () => {
|
||||
it('UI-06: No data state display', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={[]}
|
||||
@@ -716,7 +716,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 6. CLEAR ACTIONS =====
|
||||
describe('Clear Actions (CA)', () => {
|
||||
test('CA-01: Ctrl+A selects all chips', async () => {
|
||||
it('CA-01: Ctrl+A selects all chips', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -760,7 +760,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('CA-02: Clear icon removes all selections', async () => {
|
||||
it('CA-02: Clear icon removes all selections', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -777,7 +777,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('CA-03: Individual chip removal', async () => {
|
||||
it('CA-03: Individual chip removal', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -790,7 +790,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
const removeButtons = document.querySelectorAll(
|
||||
'.ant-select-selection-item-remove',
|
||||
);
|
||||
expect(removeButtons.length).toBe(2);
|
||||
expect(removeButtons).toHaveLength(2);
|
||||
|
||||
await user.click(removeButtons[1] as Element);
|
||||
|
||||
@@ -804,7 +804,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 7. SAVE AND SELECTION TRIGGERS =====
|
||||
describe('Save and Selection Triggers (ST)', () => {
|
||||
test('ST-01: ESC triggers save action', async () => {
|
||||
it('ST-01: ESC triggers save action', async () => {
|
||||
const mockDropdownChange = jest.fn();
|
||||
|
||||
renderWithVirtuoso(
|
||||
@@ -837,7 +837,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('ST-02: Mouse selection works', async () => {
|
||||
it('ST-02: Mouse selection works', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -859,7 +859,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('ST-03: ENTER in input field creates custom value', async () => {
|
||||
it('ST-03: ENTER in input field creates custom value', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -892,7 +892,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('ST-04: Search text persistence', async () => {
|
||||
it('ST-04: Search text persistence', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -932,7 +932,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 8. SPECIAL OPTIONS AND STATES =====
|
||||
describe('Special Options and States (SO)', () => {
|
||||
test('SO-01: ALL option appears first and separated', async () => {
|
||||
it('SO-01: ALL option appears first and separated', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -954,7 +954,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SO-02: ALL selection behavior', async () => {
|
||||
it('SO-02: ALL selection behavior', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -981,7 +981,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('SO-03: ALL tag display when all selected', () => {
|
||||
it('SO-03: ALL tag display when all selected', () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -996,7 +996,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('SO-04: Footer information display', async () => {
|
||||
it('SO-04: Footer information display', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1017,7 +1017,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== GROUPED OPTIONS SUPPORT =====
|
||||
describe('Grouped Options Support', () => {
|
||||
test('handles grouped options correctly', async () => {
|
||||
it('handles grouped options correctly', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockGroupedOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1041,7 +1041,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== ACCESSIBILITY TESTS =====
|
||||
describe('Accessibility', () => {
|
||||
test('has proper ARIA attributes', async () => {
|
||||
it('has proper ARIA attributes', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1058,7 +1058,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('supports screen reader navigation', async () => {
|
||||
it('supports screen reader navigation', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1079,7 +1079,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 9. ADVANCED KEYBOARD NAVIGATION =====
|
||||
describe('Advanced Keyboard Navigation (AKN)', () => {
|
||||
test('AKN-01: Shift + Arrow + Del chip deletion', async () => {
|
||||
it('AKN-01: Shift + Arrow + Del chip deletion', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -1137,7 +1137,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).toHaveFocus();
|
||||
});
|
||||
|
||||
test('AKN-03: Mouse out closes dropdown', async () => {
|
||||
it('AKN-03: Mouse out closes dropdown', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1164,7 +1164,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 10. ADVANCED FILTERING AND HIGHLIGHTING =====
|
||||
describe('Advanced Filtering and Highlighting (AFH)', () => {
|
||||
test('AFH-01: Highlighted values pushed to top', async () => {
|
||||
it('AFH-01: Highlighted values pushed to top', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1220,7 +1220,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('AFH-02: Distinction between selection Enter and save Enter', async () => {
|
||||
it('AFH-02: Distinction between selection Enter and save Enter', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1267,7 +1267,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 11. ADVANCED CLEAR ACTIONS =====
|
||||
describe('Advanced Clear Actions (ACA)', () => {
|
||||
test('ACA-01: Clear action waiting behavior', async () => {
|
||||
it('ACA-01: Clear action waiting behavior', async () => {
|
||||
const mockOnChangeWithDelay = jest.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -1300,7 +1300,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 12. ADVANCED UI STATES =====
|
||||
describe('Advanced UI States (AUS)', () => {
|
||||
test('AUS-01: No data with previous value selected', async () => {
|
||||
it('AUS-01: No data with previous value selected', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={[]}
|
||||
@@ -1322,7 +1322,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(screen.getByText('previous-value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('AUS-02: Always editable accessibility', async () => {
|
||||
it('AUS-02: Always editable accessibility', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -1338,7 +1338,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('AUS-03: Sufficient space for search value', async () => {
|
||||
it('AUS-03: Sufficient space for search value', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -1372,7 +1372,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 13. REGEX AND CUSTOM VALUES =====
|
||||
describe('Regex and Custom Values (RCV)', () => {
|
||||
test('RCV-01: Regex pattern support', async () => {
|
||||
it('RCV-01: Regex pattern support', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
@@ -1418,7 +1418,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('RCV-02: Custom values treated as normal dropdown values', async () => {
|
||||
it('RCV-02: Custom values treated as normal dropdown values', async () => {
|
||||
const customOptions = [
|
||||
...mockOptions,
|
||||
{ label: 'custom-value', value: 'custom-value', type: 'custom' as const },
|
||||
@@ -1456,7 +1456,7 @@ describe('CustomMultiSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 14. DROPDOWN PERSISTENCE =====
|
||||
describe('Dropdown Persistence (DP)', () => {
|
||||
test('DP-01: Dropdown stays open for non-save actions', async () => {
|
||||
it('DP-01: Dropdown stays open for non-save actions', async () => {
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect options={mockOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 1. CUSTOM VALUES SUPPORT =====
|
||||
describe('Custom Values Support (CS)', () => {
|
||||
test('CS-02: Partial matches create custom values', async () => {
|
||||
it('CS-02: Partial matches create custom values', async () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={mockOptions}
|
||||
@@ -110,7 +110,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('CS-03: Exact match behavior', async () => {
|
||||
it('CS-03: Exact match behavior', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -161,7 +161,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 2. SEARCH AND FILTERING =====
|
||||
describe('Search and Filtering (SF)', () => {
|
||||
test('SF-01: Selected values pushed to top', async () => {
|
||||
it('SF-01: Selected values pushed to top', async () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={mockOptions}
|
||||
@@ -185,7 +185,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SF-02: Real-time search filtering', async () => {
|
||||
it('SF-02: Real-time search filtering', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -228,7 +228,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SF-03: Search highlighting', async () => {
|
||||
it('SF-03: Search highlighting', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -257,7 +257,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SF-04: Search with partial matches', async () => {
|
||||
it('SF-04: Search with partial matches', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -298,7 +298,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 3. KEYBOARD NAVIGATION =====
|
||||
describe('Keyboard Navigation (KN)', () => {
|
||||
test('KN-01: Arrow key navigation in dropdown', async () => {
|
||||
it('KN-01: Arrow key navigation in dropdown', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -329,7 +329,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('KN-02: Tab navigation to dropdown', async () => {
|
||||
it('KN-02: Tab navigation to dropdown', async () => {
|
||||
render(
|
||||
<div>
|
||||
<input data-testid="prev-input" />
|
||||
@@ -355,7 +355,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('KN-03: Enter selection in dropdown', async () => {
|
||||
it('KN-03: Enter selection in dropdown', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -376,7 +376,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('KN-04: Space key selection', async () => {
|
||||
it('KN-04: Space key selection', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -396,7 +396,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('KN-05: Tab navigation within dropdown', async () => {
|
||||
it('KN-05: Tab navigation within dropdown', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -417,7 +417,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 4. UI/UX BEHAVIORS =====
|
||||
describe('UI/UX Behaviors (UI)', () => {
|
||||
test('UI-01: Loading state does not block interaction', async () => {
|
||||
it('UI-01: Loading state does not block interaction', async () => {
|
||||
render(
|
||||
<CustomSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -429,7 +429,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).toHaveFocus();
|
||||
});
|
||||
|
||||
test('UI-02: Component remains editable in all states', () => {
|
||||
it('UI-02: Component remains editable in all states', () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={mockOptions}
|
||||
@@ -444,7 +444,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('UI-03: Loading state display in footer', async () => {
|
||||
it('UI-03: Loading state display in footer', async () => {
|
||||
render(
|
||||
<CustomSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -458,7 +458,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-04: Error state display in footer', async () => {
|
||||
it('UI-04: Error state display in footer', async () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={mockOptions}
|
||||
@@ -477,7 +477,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('UI-05: No data state display', async () => {
|
||||
it('UI-05: No data state display', async () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={[]}
|
||||
@@ -497,7 +497,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 6. SAVE AND SELECTION TRIGGERS =====
|
||||
describe('Save and Selection Triggers (ST)', () => {
|
||||
test('ST-01: Mouse selection works', async () => {
|
||||
it('ST-01: Mouse selection works', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -520,7 +520,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 7. GROUPED OPTIONS SUPPORT =====
|
||||
describe('Grouped Options Support', () => {
|
||||
test('handles grouped options correctly', async () => {
|
||||
it('handles grouped options correctly', async () => {
|
||||
render(
|
||||
<CustomSelect options={mockGroupedOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -541,7 +541,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('grouped option selection works', async () => {
|
||||
it('grouped option selection works', async () => {
|
||||
render(
|
||||
<CustomSelect options={mockGroupedOptions} onChange={mockOnChange} />,
|
||||
);
|
||||
@@ -566,7 +566,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 8. ACCESSIBILITY =====
|
||||
describe('Accessibility', () => {
|
||||
test('has proper ARIA attributes', async () => {
|
||||
it('has proper ARIA attributes', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -580,7 +580,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('supports screen reader navigation', async () => {
|
||||
it('supports screen reader navigation', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -596,7 +596,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('has proper focus management', async () => {
|
||||
it('has proper focus management', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -617,7 +617,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 10. EDGE CASES =====
|
||||
describe('Edge Cases', () => {
|
||||
test('handles special characters in options', async () => {
|
||||
it('handles special characters in options', async () => {
|
||||
const specialOptions = [
|
||||
{ label: 'Option with spaces', value: 'option-with-spaces' },
|
||||
{ label: 'Option-with-dashes', value: 'option-with-dashes' },
|
||||
@@ -638,7 +638,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('handles extremely long option labels', async () => {
|
||||
it('handles extremely long option labels', async () => {
|
||||
const longLabelOptions = [
|
||||
{
|
||||
label:
|
||||
@@ -663,7 +663,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 11. ADVANCED KEYBOARD NAVIGATION =====
|
||||
describe('Advanced Keyboard Navigation (AKN)', () => {
|
||||
test('AKN-01: Mouse out closes dropdown', async () => {
|
||||
it('AKN-01: Mouse out closes dropdown', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -684,7 +684,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('AKN-02: TAB navigation from input to dropdown', async () => {
|
||||
it('AKN-02: TAB navigation from input to dropdown', async () => {
|
||||
render(
|
||||
<div>
|
||||
<input data-testid="prev-input" />
|
||||
@@ -722,7 +722,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 12. ADVANCED FILTERING AND HIGHLIGHTING =====
|
||||
describe('Advanced Filtering and Highlighting (AFH)', () => {
|
||||
test('AFH-01: Highlighted values pushed to top', async () => {
|
||||
it('AFH-01: Highlighted values pushed to top', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -776,7 +776,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('AFH-02: Distinction between selection Enter and save Enter', async () => {
|
||||
it('AFH-02: Distinction between selection Enter and save Enter', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -830,7 +830,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 13. ADVANCED CLEAR ACTIONS =====
|
||||
describe('Advanced Clear Actions (ACA)', () => {
|
||||
test('ACA-01: Clear action waiting behavior', async () => {
|
||||
it('ACA-01: Clear action waiting behavior', async () => {
|
||||
const mockOnChangeWithDelay = jest.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -860,7 +860,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
expect(mockOnChangeWithDelay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ACA-02: Single select clear behavior like text input', async () => {
|
||||
it('ACA-02: Single select clear behavior like text input', async () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={mockOptions}
|
||||
@@ -883,7 +883,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 14. ADVANCED UI STATES =====
|
||||
describe('Advanced UI States (AUS)', () => {
|
||||
test('AUS-01: No data with previous value selected', async () => {
|
||||
it('AUS-01: No data with previous value selected', async () => {
|
||||
render(
|
||||
<CustomSelect
|
||||
options={[]}
|
||||
@@ -905,7 +905,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
expect(screen.getAllByText('previous-value')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('AUS-02: Always editable accessibility', async () => {
|
||||
it('AUS-02: Always editable accessibility', async () => {
|
||||
render(
|
||||
<CustomSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -921,7 +921,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
expect(combobox).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('AUS-03: Sufficient space for search value', async () => {
|
||||
it('AUS-03: Sufficient space for search value', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -950,7 +950,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('AUS-04: No spinners blocking user interaction', async () => {
|
||||
it('AUS-04: No spinners blocking user interaction', async () => {
|
||||
render(
|
||||
<CustomSelect options={mockOptions} onChange={mockOnChange} loading />,
|
||||
);
|
||||
@@ -976,7 +976,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 15. REGEX AND CUSTOM VALUES =====
|
||||
describe('Regex and Custom Values (RCV)', () => {
|
||||
test('RCV-01: Regex pattern support', async () => {
|
||||
it('RCV-01: Regex pattern support', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
@@ -1019,7 +1019,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('RCV-02: Custom values treated as normal dropdown values', async () => {
|
||||
it('RCV-02: Custom values treated as normal dropdown values', async () => {
|
||||
const customOptions = [
|
||||
...mockOptions,
|
||||
{ label: 'custom-value', value: 'custom-value', type: 'custom' as const },
|
||||
@@ -1051,7 +1051,7 @@ describe('CustomSelect - Comprehensive Tests', () => {
|
||||
|
||||
// ===== 16. DROPDOWN PERSISTENCE =====
|
||||
describe('Dropdown Persistence (DP)', () => {
|
||||
test('DP-01: Dropdown closes only on save actions', async () => {
|
||||
it('DP-01: Dropdown closes only on save actions', async () => {
|
||||
render(<CustomSelect options={mockOptions} onChange={mockOnChange} />);
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 1. INTEGRATION WITH CUSTOMSELECT =====
|
||||
describe('CustomSelect Integration (VI)', () => {
|
||||
test('VI-01: Single select variable integration', async () => {
|
||||
it('VI-01: Single select variable integration', async () => {
|
||||
const variable = createMockVariable({
|
||||
multiSelect: false,
|
||||
type: 'CUSTOM',
|
||||
@@ -130,7 +130,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 2. INTEGRATION WITH CUSTOMMULTISELECT =====
|
||||
describe('CustomMultiSelect Integration (VI)', () => {
|
||||
test('VI-02: Multi select variable integration', async () => {
|
||||
it('VI-02: Multi select variable integration', async () => {
|
||||
const variable = createMockVariable({
|
||||
multiSelect: true,
|
||||
type: 'CUSTOM',
|
||||
@@ -174,7 +174,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 3. TEXTBOX VARIABLE TYPE =====
|
||||
describe('Textbox Variable Integration', () => {
|
||||
test('VI-03: Textbox variable handling', async () => {
|
||||
it('VI-03: Textbox variable handling', async () => {
|
||||
const variable = createMockVariable({
|
||||
type: 'TEXTBOX',
|
||||
selectedValue: 'initial-value',
|
||||
@@ -219,7 +219,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 4. VALUE PERSISTENCE AND STATE MANAGEMENT =====
|
||||
describe('Value Persistence and State Management', () => {
|
||||
test('VI-04: All selected state handling', () => {
|
||||
it('VI-04: All selected state handling', () => {
|
||||
const variable = createMockVariable({
|
||||
multiSelect: true,
|
||||
type: 'CUSTOM',
|
||||
@@ -243,7 +243,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('VI-05: Dropdown behavior with temporary selections', async () => {
|
||||
it('VI-05: Dropdown behavior with temporary selections', async () => {
|
||||
const variable = createMockVariable({
|
||||
multiSelect: true,
|
||||
type: 'CUSTOM',
|
||||
@@ -277,7 +277,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 6. ACCESSIBILITY AND USER EXPERIENCE =====
|
||||
describe('Accessibility and User Experience', () => {
|
||||
test('VI-06: Variable description tooltip', async () => {
|
||||
it('VI-06: Variable description tooltip', async () => {
|
||||
const variable = createMockVariable({
|
||||
description: 'This variable controls the service selection',
|
||||
type: 'CUSTOM',
|
||||
@@ -310,7 +310,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('VI-07: Variable name display', () => {
|
||||
it('VI-07: Variable name display', () => {
|
||||
const variable = createMockVariable({
|
||||
name: 'service_name',
|
||||
type: 'CUSTOM',
|
||||
@@ -331,7 +331,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
expect(screen.getByText('$service_name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('VI-08: Max tag count behavior', async () => {
|
||||
it('VI-08: Max tag count behavior', async () => {
|
||||
const variable = createMockVariable({
|
||||
multiSelect: true,
|
||||
type: 'CUSTOM',
|
||||
@@ -365,7 +365,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 8. SEARCH INTERACTION TESTS =====
|
||||
describe('Search Interaction Tests', () => {
|
||||
test('VI-14: Search persistence across dropdown open/close', async () => {
|
||||
it('VI-14: Search persistence across dropdown open/close', async () => {
|
||||
const variable = createMockVariable({
|
||||
type: 'CUSTOM',
|
||||
customValue: 'option1,option2,option3',
|
||||
@@ -417,7 +417,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 9. ADVANCED KEYBOARD NAVIGATION =====
|
||||
describe('Advanced Keyboard Navigation (VI)', () => {
|
||||
test('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => {
|
||||
it('VI-15: Shift + Arrow + Del chip deletion in multiselect', async () => {
|
||||
const variable = createMockVariable({
|
||||
type: 'CUSTOM',
|
||||
customValue: 'option1,option2,option3',
|
||||
@@ -461,7 +461,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 11. ADVANCED UI STATES =====
|
||||
describe('Advanced UI States (VI)', () => {
|
||||
test('VI-19: No data with previous value selected in variable', async () => {
|
||||
it('VI-19: No data with previous value selected in variable', async () => {
|
||||
const variable = createMockVariable({
|
||||
type: 'CUSTOM',
|
||||
customValue: '',
|
||||
@@ -499,7 +499,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
expect(combobox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('VI-20: Always editable accessibility in variable', async () => {
|
||||
it('VI-20: Always editable accessibility in variable', async () => {
|
||||
const variable = createMockVariable({
|
||||
type: 'CUSTOM',
|
||||
customValue: 'option1,option2',
|
||||
@@ -530,7 +530,7 @@ describe('VariableItem Integration Tests', () => {
|
||||
|
||||
// ===== 13. DROPDOWN PERSISTENCE =====
|
||||
describe('Dropdown Persistence (VI)', () => {
|
||||
test('VI-24: Dropdown stays open for non-save actions in variable', async () => {
|
||||
it('VI-24: Dropdown stays open for non-save actions in variable', async () => {
|
||||
const variable = createMockVariable({
|
||||
type: 'CUSTOM',
|
||||
customValue: 'option1,option2,option3',
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('OverflowInputToolTip', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
|
||||
it('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
|
||||
mockOverflow(150, 250); // clientWidth >= maxAutoWidth (150), scrollWidth > clientWidth
|
||||
|
||||
render(<OverflowInputToolTip value="Very long overflowing text" />);
|
||||
@@ -64,7 +64,7 @@ describe('OverflowInputToolTip', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does NOT show tooltip when content does not overflow', async () => {
|
||||
it('does NOT show tooltip when content does not overflow', async () => {
|
||||
mockOverflow(150, 100); // content fits (scrollWidth <= clientWidth)
|
||||
|
||||
render(<OverflowInputToolTip value="Short text" />);
|
||||
@@ -76,7 +76,7 @@ describe('OverflowInputToolTip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
|
||||
it('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
|
||||
mockOverflow(100, 250); // clientWidth < maxAutoWidth (150), scrollWidth > clientWidth
|
||||
|
||||
render(<OverflowInputToolTip value="Long but input not clamped" />);
|
||||
@@ -88,7 +88,7 @@ describe('OverflowInputToolTip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('uncontrolled input allows typing', async () => {
|
||||
it('uncontrolled input allows typing', async () => {
|
||||
render(<OverflowInputToolTip defaultValue="Init" />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
@@ -97,7 +97,7 @@ describe('OverflowInputToolTip', () => {
|
||||
expect(input).toHaveValue('InitABC');
|
||||
});
|
||||
|
||||
test('disabled input never shows tooltip even if overflowing', async () => {
|
||||
it('disabled input never shows tooltip even if overflowing', async () => {
|
||||
mockOverflow(150, 300);
|
||||
|
||||
render(<OverflowInputToolTip value="Overflowing!" disabled />);
|
||||
@@ -109,7 +109,7 @@ describe('OverflowInputToolTip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
|
||||
it('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
|
||||
const { container } = render(<OverflowInputToolTip value="Snapshot" />);
|
||||
const mirror = container.querySelector('.overflow-input-mirror');
|
||||
const input = container.querySelector('input') as HTMLInputElement | null;
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('traceOperatorContextUtils', () => {
|
||||
null,
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
expect(context).toStrictEqual({
|
||||
tokenType: TraceOperatorGrammarLexer.IDENTIFIER,
|
||||
text: 'test',
|
||||
start: 0,
|
||||
@@ -62,7 +62,7 @@ describe('traceOperatorContextUtils', () => {
|
||||
false,
|
||||
);
|
||||
|
||||
expect(context).toEqual({
|
||||
expect(context).toStrictEqual({
|
||||
tokenType: TraceOperatorGrammarLexer.IDENTIFIER,
|
||||
text: 'test',
|
||||
start: 0,
|
||||
@@ -193,7 +193,7 @@ describe('traceOperatorContextUtils', () => {
|
||||
it('should return default context for empty query', () => {
|
||||
const result = getTraceOperatorContextAtCursor('', 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: 0,
|
||||
@@ -211,7 +211,7 @@ describe('traceOperatorContextUtils', () => {
|
||||
it('should return default context for null query', () => {
|
||||
const result = getTraceOperatorContextAtCursor(null as any, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: 0,
|
||||
@@ -229,7 +229,7 @@ describe('traceOperatorContextUtils', () => {
|
||||
it('should return default context for undefined query', () => {
|
||||
const result = getTraceOperatorContextAtCursor(undefined as any, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
tokenType: -1,
|
||||
text: '',
|
||||
start: 0,
|
||||
|
||||
@@ -8,21 +8,21 @@ const makeTraceOperator = (expression: string): IBuilderTraceOperator =>
|
||||
describe('getInvolvedQueriesInTraceOperator', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = getInvolvedQueriesInTraceOperator([]);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('extracts identifiers from expression', () => {
|
||||
const result = getInvolvedQueriesInTraceOperator([
|
||||
makeTraceOperator('A => B'),
|
||||
]);
|
||||
expect(result).toEqual(['A', 'B']);
|
||||
expect(result).toStrictEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('extracts identifiers from complex expression', () => {
|
||||
const result = getInvolvedQueriesInTraceOperator([
|
||||
makeTraceOperator('A => (NOT B || C)'),
|
||||
]);
|
||||
expect(result).toEqual(['A', 'B', 'C']);
|
||||
expect(result).toStrictEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('filters out querynames from complex expression', () => {
|
||||
@@ -31,7 +31,7 @@ describe('getInvolvedQueriesInTraceOperator', () => {
|
||||
'(A1 && (NOT B2 || (C3 -> (D4 && E5)))) => ((F6 || G7) && (NOT (H8 -> I9)))',
|
||||
),
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
'A1',
|
||||
'B2',
|
||||
'C3',
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('previousQuery.utils', () => {
|
||||
saveAsPreviousQuery(key, sampleQuery);
|
||||
|
||||
const fromStore = getPreviousQueryFromKey(key);
|
||||
expect(fromStore).toEqual(sampleQuery);
|
||||
expect(fromStore).toStrictEqual(sampleQuery);
|
||||
});
|
||||
|
||||
it('saveAsPreviousQuery merges multiple entries and removeKeyFromPreviousQuery deletes one', () => {
|
||||
|
||||
@@ -22,18 +22,20 @@ describe('convertFiltersToExpression', () => {
|
||||
|
||||
it('should handle empty, null, and undefined inputs', () => {
|
||||
// Test null and undefined
|
||||
expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' });
|
||||
expect(convertFiltersToExpression(undefined as any)).toEqual({
|
||||
expect(convertFiltersToExpression(null as any)).toStrictEqual({
|
||||
expression: '',
|
||||
});
|
||||
expect(convertFiltersToExpression(undefined as any)).toStrictEqual({
|
||||
expression: '',
|
||||
});
|
||||
|
||||
// Test empty filters
|
||||
expect(convertFiltersToExpression({ items: [], op: 'AND' })).toEqual({
|
||||
expect(convertFiltersToExpression({ items: [], op: 'AND' })).toStrictEqual({
|
||||
expression: '',
|
||||
});
|
||||
expect(
|
||||
convertFiltersToExpression({ items: undefined, op: 'AND' } as any),
|
||||
).toEqual({ expression: '' });
|
||||
).toStrictEqual({ expression: '' });
|
||||
});
|
||||
|
||||
it('should convert basic comparison operators with proper value formatting', () => {
|
||||
@@ -92,7 +94,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"service = 'api-gateway' AND status != 'error' AND duration > 100 AND count <= 50 AND is_active = true AND enabled = false AND count = 0 AND regex REGEXP '.*'",
|
||||
});
|
||||
@@ -124,7 +126,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"message = 'user\\'s data' AND description = '' AND path = '/api/v1/users'",
|
||||
});
|
||||
@@ -162,7 +164,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"service in ['api-gateway', 'user-service', 'auth-service'] AND status in ['success'] AND tags in [] AND name in ['John\\'s', 'Mary\\'s', 'Bob']",
|
||||
});
|
||||
@@ -224,7 +226,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"service NOT IN ['api-gateway', 'user-service'] AND message NOT LIKE 'error' AND path NOT REGEXP '/api/.*' AND service NOT IN ['api-gateway'] AND user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
|
||||
});
|
||||
@@ -268,7 +270,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"user_id exists AND user_id exists AND has(tags, 'production') AND hasAny(tags, ['production', 'staging']) AND hasAll(tags, ['production', 'monitoring'])",
|
||||
});
|
||||
@@ -312,7 +314,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"service = 'api-gateway' AND status = 'success' AND service in ['api-gateway']",
|
||||
});
|
||||
@@ -362,7 +364,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"service in ['api-gateway', 'user-service'] AND user_id exists AND has(tags, 'production') AND duration > 100 AND status NOT IN ['error', 'timeout'] AND method = 'POST'",
|
||||
});
|
||||
@@ -412,7 +414,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"count = 0 AND score > 100 AND limit >= 50 AND threshold < 1000 AND max_value <= 999 AND values in ['1', '2', '3', '4', '5']",
|
||||
});
|
||||
@@ -456,7 +458,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"is_active = true AND is_deleted = false AND email = 'user@example.com' AND description = 'Contains \"quotes\" and \\'apostrophes\\'' AND path = '/api/v1/users/123?filter=true'",
|
||||
});
|
||||
@@ -506,7 +508,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"has(tags, 'production') AND hasAny(labels, ['env:prod', 'service:api']) AND hasAll(metadata, ['version:1.0', 'team:backend']) AND services in ['api-gateway', 'user-service', 'auth-service', 'payment-service'] AND excluded_services NOT IN ['legacy-service', 'deprecated-service'] AND status_codes in ['200', '201', '400', '500']",
|
||||
});
|
||||
@@ -544,7 +546,7 @@ describe('convertFiltersToExpression', () => {
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
expression:
|
||||
"user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
|
||||
});
|
||||
@@ -568,7 +570,7 @@ describe('convertFiltersToExpression', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.filters).toEqual(filters);
|
||||
expect(result.filters).toStrictEqual(filters);
|
||||
expect(result.filter.expression).toBe("service.name = 'test-service'");
|
||||
});
|
||||
|
||||
@@ -583,7 +585,7 @@ describe('convertFiltersToExpression', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.filters).toEqual(filters);
|
||||
expect(result.filters).toStrictEqual(filters);
|
||||
expect(result.filter.expression).toBe('');
|
||||
});
|
||||
|
||||
@@ -611,7 +613,7 @@ describe('convertFiltersToExpression', () => {
|
||||
expect(result.filter).toBeDefined();
|
||||
expect(result.filter.expression).toBe("service.name = 'updated-service'");
|
||||
// Ensure parser can parse the existing query
|
||||
expect(extractQueryPairs(existingQuery)).toEqual(
|
||||
expect(extractQueryPairs(existingQuery)).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'service.name',
|
||||
@@ -805,7 +807,7 @@ describe('convertAggregationToExpression', () => {
|
||||
temporality: 'delta',
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
metricName: 'test_metric',
|
||||
timeAggregation: 'avg',
|
||||
@@ -825,9 +827,11 @@ describe('convertAggregationToExpression', () => {
|
||||
spaceAggregation: 'noop',
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
metricName: 'test_metric',
|
||||
reduceTo: undefined,
|
||||
temporality: undefined,
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'count',
|
||||
},
|
||||
@@ -841,9 +845,11 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.METRICS,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
metricName: '',
|
||||
reduceTo: undefined,
|
||||
temporality: undefined,
|
||||
timeAggregation: 'sum',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
@@ -858,7 +864,7 @@ describe('convertAggregationToExpression', () => {
|
||||
alias: 'trace_alias',
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
expression: 'count(test_metric)',
|
||||
alias: 'trace_alias',
|
||||
@@ -874,7 +880,7 @@ describe('convertAggregationToExpression', () => {
|
||||
alias: 'log_alias',
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
expression: 'avg(test_metric)',
|
||||
alias: 'log_alias',
|
||||
@@ -889,7 +895,7 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
@@ -903,7 +909,7 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
expression: 'sum(test_metric)',
|
||||
},
|
||||
@@ -917,9 +923,11 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.METRICS,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
metricName: 'test_metric',
|
||||
reduceTo: undefined,
|
||||
temporality: undefined,
|
||||
timeAggregation: 'max',
|
||||
spaceAggregation: 'max',
|
||||
},
|
||||
@@ -933,7 +941,7 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.METRICS,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
metricName: 'test_metric',
|
||||
timeAggregation: 'sum',
|
||||
@@ -951,7 +959,7 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
@@ -965,7 +973,7 @@ describe('convertAggregationToExpression', () => {
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
|
||||
@@ -471,6 +471,6 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
|
||||
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
|
||||
expect(filterForServiceName.op).toBe('in');
|
||||
expect(filterForServiceName.value).toEqual(['mq-kafka', 'otel-demo']);
|
||||
expect(filterForServiceName.value).toStrictEqual(['mq-kafka', 'otel-demo']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -323,7 +323,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Edit quick filters'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
expect(addedSection).toContainElement(
|
||||
@@ -454,7 +456,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
const requestBody = putHandler.mock.calls[0][0];
|
||||
expect(requestBody.filters).toEqual(
|
||||
expect(requestBody.filters).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
|
||||
]),
|
||||
@@ -535,12 +537,16 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(getCalls).toBe(2);
|
||||
});
|
||||
@@ -578,7 +584,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
|
||||
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
@@ -628,7 +636,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
|
||||
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
@@ -657,6 +667,8 @@ describe('Quick Filters refetch behavior', () => {
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
|
||||
|
||||
expect(await screen.findByText('No filters found')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('No filters found'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const testRoutes: RouteTabProps['routes'] = [
|
||||
];
|
||||
|
||||
describe('RouteTab component', () => {
|
||||
test('renders correctly', () => {
|
||||
it('renders correctly', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
@@ -39,7 +39,7 @@ describe('RouteTab component', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Tab2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders correct number of tabs', () => {
|
||||
it('renders correct number of tabs', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
@@ -47,10 +47,10 @@ describe('RouteTab component', () => {
|
||||
</Router>,
|
||||
);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs.length).toBe(testRoutes.length);
|
||||
expect(tabs).toHaveLength(testRoutes.length);
|
||||
});
|
||||
|
||||
test('sets provided activeKey as active tab', () => {
|
||||
it('sets provided activeKey as active tab', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
@@ -62,7 +62,7 @@ describe('RouteTab component', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('navigates to correct route on tab click', () => {
|
||||
it('navigates to correct route on tab click', () => {
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
<Router history={history}>
|
||||
@@ -74,7 +74,7 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
test('calls onChangeHandler on tab change', () => {
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
render(
|
||||
|
||||
@@ -70,9 +70,9 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
it('renders key data from prop when edit-key param is set', async () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
await screen.findByDisplayValue('Original Key Name'),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByDisplayValue('Original Key Name'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
|
||||
const latestUrlUpdate =
|
||||
onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]?.[0];
|
||||
expect(latestUrlUpdate).toEqual(
|
||||
expect(latestUrlUpdate).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
queryString: expect.any(String),
|
||||
}),
|
||||
@@ -134,9 +134,9 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
|
||||
|
||||
// Same dialog, now showing revoke confirmation
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Revoking this key will permanently invalidate it/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -104,7 +104,9 @@ describe('ServiceAccountDrawer', () => {
|
||||
it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', async () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(await screen.findByDisplayValue('CI Bot')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByDisplayValue('CI Bot'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
@@ -272,11 +274,11 @@ describe('ServiceAccountDrawer', () => {
|
||||
|
||||
renderDrawer();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
await expect(
|
||||
screen.findByText(
|
||||
/An unexpected error occurred while fetching service account details/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -349,11 +351,11 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Name update.*name update failed/i, undefined, {
|
||||
await expect(
|
||||
screen.findByText(/Name update.*name update failed/i, undefined, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role add failure shows SaveErrorItem with the role name context', async () => {
|
||||
@@ -385,15 +387,11 @@ describe('ServiceAccountDrawer – save-error UX', () => {
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Role 'signoz-viewer'.*role assign failed/i,
|
||||
undefined,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Role 'signoz-viewer'.*role assign failed/i, undefined, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role add retries on 429 then succeeds without showing an error', async () => {
|
||||
|
||||
@@ -7,9 +7,7 @@ import { SpinerStyle } from './styles';
|
||||
function Spinner({ size, tip, height, style }: SpinnerProps): JSX.Element {
|
||||
return (
|
||||
<SpinerStyle height={height} style={style}>
|
||||
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />}>
|
||||
<div />
|
||||
</Spin>
|
||||
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />} />
|
||||
</SpinerStyle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,9 @@ describe('TanStackCustomTableRow', () => {
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByTestId('mocked-row-cells'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', () => {
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('TanStackRowCells', () => {
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
|
||||
await expect(screen.findByText('expanded-r1')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('new tab click', () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('useColumnState', () => {
|
||||
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
||||
expect(state.hiddenColumnIds).toStrictEqual(['b']);
|
||||
});
|
||||
|
||||
it('does not initialize without storageKey', () => {
|
||||
@@ -61,7 +61,7 @@ describe('useColumnState', () => {
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ b: false });
|
||||
expect(result.current.columnVisibility).toStrictEqual({ b: false });
|
||||
});
|
||||
|
||||
it('applies visibilityBehavior for grouped state', () => {
|
||||
@@ -79,13 +79,15 @@ describe('useColumnState', () => {
|
||||
const { result: notGrouped } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
|
||||
);
|
||||
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
|
||||
expect(notGrouped.current.columnVisibility).toStrictEqual({
|
||||
grouped: false,
|
||||
});
|
||||
|
||||
// Grouped
|
||||
const { result: grouped } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
||||
);
|
||||
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
|
||||
expect(grouped.current.columnVisibility).toStrictEqual({ ungrouped: false });
|
||||
});
|
||||
|
||||
it('combines store hidden + visibilityBehavior', () => {
|
||||
@@ -103,7 +105,10 @@ describe('useColumnState', () => {
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
|
||||
expect(result.current.columnVisibility).toStrictEqual({
|
||||
a: false,
|
||||
b: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +124,7 @@ describe('useColumnState', () => {
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
@@ -138,7 +143,7 @@ describe('useColumnState', () => {
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
@@ -157,7 +162,7 @@ describe('useColumnState', () => {
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'pinned',
|
||||
'b',
|
||||
'a',
|
||||
@@ -181,7 +186,7 @@ describe('useColumnState', () => {
|
||||
result.current.hideColumn('a');
|
||||
});
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
||||
expect(result.current.columnVisibility).toStrictEqual({ a: false });
|
||||
});
|
||||
|
||||
it('showColumn shows a column', () => {
|
||||
@@ -195,13 +200,13 @@ describe('useColumnState', () => {
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
||||
expect(result.current.columnVisibility).toStrictEqual({ a: false });
|
||||
|
||||
act(() => {
|
||||
result.current.showColumn('a');
|
||||
});
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({});
|
||||
expect(result.current.columnVisibility).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('setColumnSizing updates sizing', () => {
|
||||
@@ -219,7 +224,7 @@ describe('useColumnState', () => {
|
||||
result.current.setColumnSizing({ a: 200 });
|
||||
});
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ a: 200 });
|
||||
expect(result.current.columnSizing).toStrictEqual({ a: 200 });
|
||||
});
|
||||
|
||||
it('setColumnOrder updates order from column array', () => {
|
||||
@@ -237,7 +242,7 @@ describe('useColumnState', () => {
|
||||
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
|
||||
});
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
|
||||
@@ -29,9 +29,9 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
||||
expect(state.columnOrder).toEqual([]);
|
||||
expect(state.columnSizing).toEqual({});
|
||||
expect(state.hiddenColumnIds).toStrictEqual(['b']);
|
||||
expect(state.columnOrder).toStrictEqual([]);
|
||||
expect(state.columnSizing).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('does not reinitialize if already exists', () => {
|
||||
@@ -124,7 +124,9 @@ describe('useColumnStore', () => {
|
||||
.getState()
|
||||
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
|
||||
expect(
|
||||
useColumnStore.getState().tables[TEST_KEY].columnSizing,
|
||||
).toStrictEqual({
|
||||
col1: 200,
|
||||
col2: 300,
|
||||
});
|
||||
@@ -144,11 +146,9 @@ describe('useColumnStore', () => {
|
||||
.getState()
|
||||
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
|
||||
'col2',
|
||||
'col1',
|
||||
'col3',
|
||||
]);
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toStrictEqual(
|
||||
['col2', 'col1', 'col3'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,9 +172,9 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['a']);
|
||||
expect(state.columnOrder).toEqual([]);
|
||||
expect(state.columnSizing).toEqual({});
|
||||
expect(state.hiddenColumnIds).toStrictEqual(['a']);
|
||||
expect(state.columnOrder).toStrictEqual([]);
|
||||
expect(state.columnSizing).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
|
||||
expect(state.hiddenColumnIds).toStrictEqual(['col1', 'col3']);
|
||||
expect(state.hiddenColumnIds).not.toContain('col2');
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
|
||||
expect(stateAfter.hiddenColumnIds).toStrictEqual(hiddenBefore);
|
||||
});
|
||||
|
||||
it('does nothing for unknown storage key', () => {
|
||||
@@ -242,7 +242,7 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
||||
expect(result.current).toEqual(['a']);
|
||||
expect(result.current).toStrictEqual(['a']);
|
||||
});
|
||||
|
||||
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
|
||||
@@ -270,7 +270,7 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
|
||||
expect(result.current).toEqual({ col1: 150 });
|
||||
expect(result.current).toStrictEqual({ col1: 150 });
|
||||
});
|
||||
|
||||
it('useColumnOrder returns order', () => {
|
||||
@@ -280,7 +280,7 @@ describe('useColumnStore', () => {
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
|
||||
expect(result.current).toEqual(['c', 'b', 'a']);
|
||||
expect(result.current).toStrictEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('returns empty defaults for unknown storageKey', () => {
|
||||
@@ -288,9 +288,9 @@ describe('useColumnStore', () => {
|
||||
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
|
||||
const { result: order } = renderHook(() => useColumnOrder('unknown'));
|
||||
|
||||
expect(hidden.current).toEqual([]);
|
||||
expect(sizing.current).toEqual({});
|
||||
expect(order.current).toEqual([]);
|
||||
expect(hidden.current).toStrictEqual([]);
|
||||
expect(sizing.current).toStrictEqual({});
|
||||
expect(order.current).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,10 @@ describe('useTableParams (local mode — enableQueryParams not set)', () => {
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +146,10 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
|
||||
const wrapper = createNuqsWrapper({ order_by: orderBy });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'name',
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates URL when setPage is called', () => {
|
||||
@@ -178,7 +184,7 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
expect(JSON.parse(lastOrderBy!)).toEqual({
|
||||
expect(JSON.parse(lastOrderBy!)).toStrictEqual({
|
||||
columnName: 'value',
|
||||
order: 'asc',
|
||||
});
|
||||
@@ -207,7 +213,7 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
result.current.setExpanded({ 'row-1': true });
|
||||
});
|
||||
|
||||
expect(result.current.expanded).toEqual({ 'row-1': true });
|
||||
expect(result.current.expanded).toStrictEqual({ 'row-1': true });
|
||||
});
|
||||
|
||||
it('toggles sort order correctly: null → asc → desc → null', () => {
|
||||
@@ -222,13 +228,19 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'id',
|
||||
order: 'asc',
|
||||
});
|
||||
|
||||
// Second click: asc → desc
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'id',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
// Third click: desc → null
|
||||
act(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatUniversalUnit } from '../formatter';
|
||||
|
||||
describe('formatUniversalUnit', () => {
|
||||
describe('Time', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Days
|
||||
[31, UniversalYAxisUnit.DAYS, '4.43 weeks'],
|
||||
[7, UniversalYAxisUnit.DAYS, '1 week'],
|
||||
@@ -48,7 +48,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Data', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Bytes
|
||||
[864, UniversalYAxisUnit.BYTES, '864 B'],
|
||||
[1000, UniversalYAxisUnit.BYTES, '1 kB'],
|
||||
@@ -91,7 +91,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Data rate', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Bytes/second
|
||||
[864, UniversalYAxisUnit.BYTES_SECOND, '864 B/s'],
|
||||
[1000, UniversalYAxisUnit.BYTES_SECOND, '1 kB/s'],
|
||||
@@ -134,7 +134,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Bit', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Bits
|
||||
[1, UniversalYAxisUnit.BITS, '1 b'],
|
||||
[250, UniversalYAxisUnit.BITS, '250 b'],
|
||||
@@ -186,7 +186,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Bit rate', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Bits/second
|
||||
[512, UniversalYAxisUnit.BITS_SECOND, '512 b/s'],
|
||||
[1000, UniversalYAxisUnit.BITS_SECOND, '1 kb/s'],
|
||||
@@ -236,7 +236,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Count', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[100, UniversalYAxisUnit.COUNT, '100'],
|
||||
[875, UniversalYAxisUnit.COUNT, '875'],
|
||||
[1000, UniversalYAxisUnit.COUNT, '1 K'],
|
||||
@@ -256,7 +256,7 @@ describe('formatUniversalUnit', () => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
it.each([
|
||||
[100, UniversalYAxisUnit.COUNT_SECOND, '100 c/s'],
|
||||
[875, UniversalYAxisUnit.COUNT_SECOND, '875 c/s'],
|
||||
[1000, UniversalYAxisUnit.COUNT_SECOND, '1K c/s'],
|
||||
@@ -267,7 +267,7 @@ describe('formatUniversalUnit', () => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
it.each([
|
||||
[100, UniversalYAxisUnit.COUNT_MINUTE, '100 c/m'],
|
||||
[875, UniversalYAxisUnit.COUNT_MINUTE, '875 c/m'],
|
||||
[1000, UniversalYAxisUnit.COUNT_MINUTE, '1K c/m'],
|
||||
@@ -280,7 +280,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Operations units', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[780, UniversalYAxisUnit.OPS_SECOND, '780 ops/s'],
|
||||
[1000, UniversalYAxisUnit.OPS_SECOND, '1K ops/s'],
|
||||
[520, UniversalYAxisUnit.OPS_MINUTE, '520 ops/m'],
|
||||
@@ -297,7 +297,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Request units', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[615, UniversalYAxisUnit.REQUESTS_SECOND, '615 req/s'],
|
||||
[1000, UniversalYAxisUnit.REQUESTS_SECOND, '1K req/s'],
|
||||
[480, UniversalYAxisUnit.REQUESTS_MINUTE, '480 req/m'],
|
||||
@@ -311,7 +311,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Read/Write units', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[505, UniversalYAxisUnit.READS_SECOND, '505 rd/s'],
|
||||
[1000, UniversalYAxisUnit.READS_SECOND, '1K rd/s'],
|
||||
[610, UniversalYAxisUnit.WRITES_SECOND, '610 wr/s'],
|
||||
@@ -335,7 +335,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('IO Operations units', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[777, UniversalYAxisUnit.IOOPS_SECOND, '777 io/s'],
|
||||
[1000, UniversalYAxisUnit.IOOPS_SECOND, '1K io/s'],
|
||||
[2500, UniversalYAxisUnit.IOOPS_SECOND, '2.5K io/s'],
|
||||
@@ -363,7 +363,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Time (additional)', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[900, UniversalYAxisUnit.DURATION_MS, '900 milliseconds'],
|
||||
[1000, UniversalYAxisUnit.DURATION_MS, '1 second'],
|
||||
[1, UniversalYAxisUnit.DURATION_MS, '1 millisecond'],
|
||||
@@ -388,7 +388,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Data (IEC/Binary)', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Bytes
|
||||
[900, UniversalYAxisUnit.BYTES_IEC, '900 B'],
|
||||
[1024, UniversalYAxisUnit.BYTES_IEC, '1 KiB'],
|
||||
@@ -430,7 +430,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Data Rate (IEC/Binary)', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Kibibytes/second
|
||||
[900, UniversalYAxisUnit.KIBIBYTES_SECOND, '900 KiB/s'],
|
||||
[1024, UniversalYAxisUnit.KIBIBYTES_SECOND, '1 MiB/s'],
|
||||
@@ -473,7 +473,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Bits (IEC)', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[900, UniversalYAxisUnit.BITS_IEC, '900 b'],
|
||||
[1024, UniversalYAxisUnit.BITS_IEC, '1 Kib'],
|
||||
[1080, UniversalYAxisUnit.BITS_IEC, '1.05 Kib'],
|
||||
@@ -483,7 +483,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Hash Rate', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// Hashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '412 H/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '1 kH/s'],
|
||||
@@ -518,7 +518,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Miscellaneous', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[742, UniversalYAxisUnit.MISC_STRING, '742'],
|
||||
[688, UniversalYAxisUnit.MISC_SHORT, '688'],
|
||||
[555, UniversalYAxisUnit.MISC_HUMIDITY, '555 %H'],
|
||||
@@ -534,7 +534,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Acceleration', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[
|
||||
875,
|
||||
UniversalYAxisUnit.ACCELERATION_METERS_PER_SECOND_SQUARED,
|
||||
@@ -553,7 +553,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Angular', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[415, UniversalYAxisUnit.ANGULAR_DEGREE, '415 °'],
|
||||
[732, UniversalYAxisUnit.ANGULAR_RADIAN, '732 rad'],
|
||||
[128, UniversalYAxisUnit.ANGULAR_GRADIAN, '128 grad'],
|
||||
@@ -565,7 +565,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Area', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[210, UniversalYAxisUnit.AREA_SQUARE_METERS, '210 m²'],
|
||||
[152, UniversalYAxisUnit.AREA_SQUARE_FEET, '152 ft²'],
|
||||
[64, UniversalYAxisUnit.AREA_SQUARE_MILES, '64 mi²'],
|
||||
@@ -575,7 +575,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('FLOPs', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
// FLOPS
|
||||
[150, UniversalYAxisUnit.FLOPS_FLOPS, '150 FLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_FLOPS, '1 kFLOPS'],
|
||||
@@ -613,7 +613,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Concentration', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[415, UniversalYAxisUnit.CONCENTRATION_PPM, '415 ppm'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_PPM, '1000 ppm'],
|
||||
[732, UniversalYAxisUnit.CONCENTRATION_PPB, '732 ppb'],
|
||||
@@ -650,7 +650,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Currency', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[812, UniversalYAxisUnit.CURRENCY_USD, '$812'],
|
||||
[645, UniversalYAxisUnit.CURRENCY_GBP, '£645'],
|
||||
[731, UniversalYAxisUnit.CURRENCY_EUR, '€731'],
|
||||
@@ -688,7 +688,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Power/Electrical', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[715, UniversalYAxisUnit.POWER_WATT, '715 W'],
|
||||
[1000, UniversalYAxisUnit.POWER_WATT, '1 kW'],
|
||||
[1080, UniversalYAxisUnit.POWER_WATT, '1.08 kW'],
|
||||
@@ -744,7 +744,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Flow', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[512, UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE, '512 gpm'],
|
||||
[1000, UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE, '1000 gpm'],
|
||||
[678, UniversalYAxisUnit.FLOW_CUBIC_METERS_PER_SECOND, '678 cms'],
|
||||
@@ -766,7 +766,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Force', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[845, UniversalYAxisUnit.FORCE_NEWTON_METERS, '845 Nm'],
|
||||
[1000, UniversalYAxisUnit.FORCE_NEWTON_METERS, '1 kNm'],
|
||||
[1080, UniversalYAxisUnit.FORCE_NEWTON_METERS, '1.08 kNm'],
|
||||
@@ -782,7 +782,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Mass', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[120, UniversalYAxisUnit.MASS_MILLIGRAM, '120 mg'],
|
||||
[120000, UniversalYAxisUnit.MASS_MILLIGRAM, '120 g'],
|
||||
[987, UniversalYAxisUnit.MASS_GRAM, '987 g'],
|
||||
@@ -796,7 +796,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Length', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[88, UniversalYAxisUnit.LENGTH_MILLIMETER, '88 mm'],
|
||||
[100, UniversalYAxisUnit.LENGTH_MILLIMETER, '100 mm'],
|
||||
[1000, UniversalYAxisUnit.LENGTH_MILLIMETER, '1 m'],
|
||||
@@ -812,7 +812,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Pressure', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[45, UniversalYAxisUnit.PRESSURE_MILLIBAR, '45 mbar'],
|
||||
[1013, UniversalYAxisUnit.PRESSURE_MILLIBAR, '1.01 bar'],
|
||||
[27, UniversalYAxisUnit.PRESSURE_BAR, '27 bar'],
|
||||
@@ -828,7 +828,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Radiation', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[452, UniversalYAxisUnit.RADIATION_BECQUEREL, '452 Bq'],
|
||||
[37, UniversalYAxisUnit.RADIATION_CURIE, '37 Ci'],
|
||||
[128, UniversalYAxisUnit.RADIATION_GRAY, '128 Gy'],
|
||||
@@ -849,7 +849,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Rotation Speed', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[345, UniversalYAxisUnit.ROTATION_SPEED_REVOLUTIONS_PER_MINUTE, '345 rpm'],
|
||||
[789, UniversalYAxisUnit.ROTATION_SPEED_HERTZ, '789 Hz'],
|
||||
[789000, UniversalYAxisUnit.ROTATION_SPEED_HERTZ, '789 kHz'],
|
||||
@@ -861,7 +861,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Temperature', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[37, UniversalYAxisUnit.TEMPERATURE_CELSIUS, '37 °C'],
|
||||
[451, UniversalYAxisUnit.TEMPERATURE_FAHRENHEIT, '451 °F'],
|
||||
[310, UniversalYAxisUnit.TEMPERATURE_KELVIN, '310 K'],
|
||||
@@ -871,7 +871,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Velocity', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[900, UniversalYAxisUnit.VELOCITY_METERS_PER_SECOND, '900 m/s'],
|
||||
[456, UniversalYAxisUnit.VELOCITY_KILOMETERS_PER_HOUR, '456 km/h'],
|
||||
[789, UniversalYAxisUnit.VELOCITY_MILES_PER_HOUR, '789 mph'],
|
||||
@@ -882,7 +882,7 @@ describe('formatUniversalUnit', () => {
|
||||
});
|
||||
|
||||
describe('Volume', () => {
|
||||
test.each([
|
||||
it.each([
|
||||
[1200, UniversalYAxisUnit.VOLUME_MILLILITER, '1.2 L'],
|
||||
[9000000, UniversalYAxisUnit.VOLUME_MILLILITER, '9 kL'],
|
||||
[9, UniversalYAxisUnit.VOLUME_LITER, '9 L'],
|
||||
|
||||
@@ -16,8 +16,8 @@ describe('YAxisUnitSelector utils', () => {
|
||||
|
||||
it('returns null or self for unknown units', () => {
|
||||
expect(mapMetricUnitToUniversalUnit('unknown_unit')).toBe('unknown_unit');
|
||||
expect(mapMetricUnitToUniversalUnit('')).toBe(null);
|
||||
expect(mapMetricUnitToUniversalUnit(undefined)).toBe(null);
|
||||
expect(mapMetricUnitToUniversalUnit('')).toBeNull();
|
||||
expect(mapMetricUnitToUniversalUnit(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('YAxisUnitSelector utils', () => {
|
||||
},
|
||||
];
|
||||
const mergedCategories = mergeCategories(categories1, categories2);
|
||||
expect(mergedCategories).toEqual([
|
||||
expect(mergedCategories).toStrictEqual([
|
||||
{
|
||||
name: YAxisCategoryNames.Data,
|
||||
units: [
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('CmdKPalette', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders navigation and settings groups and items', () => {
|
||||
it('renders navigation and settings groups and items', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
@@ -160,7 +160,7 @@ describe('CmdKPalette', () => {
|
||||
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a navigation item calls history.push with correct route', async () => {
|
||||
it('clicking a navigation item calls history.push with correct route', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
@@ -170,14 +170,14 @@ describe('CmdKPalette', () => {
|
||||
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
});
|
||||
|
||||
test('role-based filtering (basic smoke)', () => {
|
||||
it('role-based filtering (basic smoke)', () => {
|
||||
render(<CmdKPalette userRole="VIEWER" />);
|
||||
|
||||
// VIEWER still sees basic navigation items
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keyboard shortcut opens palette via setOpen', () => {
|
||||
it('keyboard shortcut opens palette via setOpen', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
|
||||
@@ -186,7 +186,7 @@ describe('CmdKPalette', () => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('items render with icons when provided', () => {
|
||||
it('items render with icons when provided', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const iconHolders = document.querySelectorAll('.cmd-item-icon');
|
||||
@@ -194,7 +194,7 @@ describe('CmdKPalette', () => {
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closing the palette via handleInvoke sets open to false', async () => {
|
||||
it('closing the palette via handleInvoke sets open to false', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
|
||||
@@ -9,7 +9,5 @@ export enum FeatureKeys {
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
ONBOARDING_V3 = 'onboarding_v3',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
/** When active, AI assistant UI, routes, and integrations are available. */
|
||||
AI_ASSISTANT_ENABLED = 'ai_assistant_enabled',
|
||||
BODY_JSON_ENABLED = 'body_json_enabled',
|
||||
}
|
||||
|
||||
@@ -87,8 +87,6 @@ const ROUTES = {
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
AI_ASSISTANT: '/ai-assistant/:conversationId',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
} as const;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Tooltip } from '@signozhq/ui';
|
||||
import { Drawer } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
export default function AIAssistantDrawer(): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
startNewConversation();
|
||||
}, [startNewConversation]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={isDrawerOpen}
|
||||
onClose={closeDrawer}
|
||||
placement="right"
|
||||
width={420}
|
||||
className="ai-assistant-drawer"
|
||||
// Suppress default close button — we render our own header
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div className="ai-assistant-drawer__header">
|
||||
<div className="ai-assistant-drawer__title">
|
||||
<MessageSquare size={16} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-drawer__actions">
|
||||
<Tooltip title="New conversation">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={handleNewConversation}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeConversationId ? (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import AIAssistantIcon from './components/AIAssistantIcon';
|
||||
import HistorySidebar from './components/HistorySidebar';
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
/**
|
||||
* Global floating modal for the AI Assistant.
|
||||
*
|
||||
* - Triggered by Cmd+J (Mac) / Ctrl+J (Windows/Linux)
|
||||
* - Escape or the × button fully closes it
|
||||
* - The − (minimize) button collapses to the side panel
|
||||
* - Mounted once in AppLayout; always in the DOM, conditionally visible
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function AIAssistantModal(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const openModal = useAIAssistantStore((s) => s.openModal);
|
||||
const closeModal = useAIAssistantStore((s) => s.closeModal);
|
||||
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// Cmd+J (Mac) / Ctrl+J (Win/Linux) — toggle modal
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
|
||||
// Don't intercept Cmd+J inside input/textarea — those are for the user
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — close modal
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, openModal, closeModal]);
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeModal, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
minimizeModal();
|
||||
setShowHistory(false);
|
||||
}, [minimizeModal]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only close when clicking the backdrop itself, not the modal card
|
||||
if (e.target === e.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
[closeModal],
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="ai-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="ai-modal">
|
||||
{/* Header */}
|
||||
<div className="ai-modal__header">
|
||||
<div className="ai-modal__title">
|
||||
<AIAssistantIcon size={16} />
|
||||
<span>AI Assistant</span>
|
||||
<kbd className="ai-modal__shortcut">⌘J</kbd>
|
||||
</div>
|
||||
|
||||
<div className="ai-modal__actions">
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle conversations"
|
||||
className={showHistory ? 'ai-panel-btn--active' : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="ai-modal__body">
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import AIAssistantIcon from './components/AIAssistantIcon';
|
||||
import HistorySidebar from './components/HistorySidebar';
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
// When user picks a conversation from the list, close the sidebar
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
// ── Resize logic ──────────────────────────────────────────────────────────
|
||||
const [panelWidth, setPanelWidth] = useState(380);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartWidth = useRef(0);
|
||||
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartWidth.current = panelWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent): void => {
|
||||
// Panel is on the right; dragging left (lower clientX) increases width
|
||||
const delta = dragStartX.current - ev.clientX;
|
||||
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
|
||||
setPanelWidth(next);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
if (!isOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-panel" style={{ width: panelWidth }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="ai-assistant-panel__resize-handle"
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
/>
|
||||
<div className="ai-assistant-panel__header">
|
||||
<div className="ai-assistant-panel__title">
|
||||
<AIAssistantIcon size={18} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-panel__actions">
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle conversations"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Bot } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
/**
|
||||
* Floating action button anchored to the bottom-right of the content area.
|
||||
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
|
||||
*/
|
||||
export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="AI Assistant">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-trigger"
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Bot size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
|
||||
import ChatInput from './components/ChatInput';
|
||||
import VirtualizedMessages from './components/VirtualizedMessages';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
import { MessageAttachment } from './types';
|
||||
import { MessageContext } from '../../api/ai/chat';
|
||||
|
||||
interface ConversationViewProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export default function ConversationView({
|
||||
conversationId,
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const conversation = useAIAssistantStore(
|
||||
(s) => s.conversations[conversationId],
|
||||
);
|
||||
const isStreamingHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
const isLoadingThread = useAIAssistantStore((s) => s.isLoadingThread);
|
||||
const pendingApprovalHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingApproval ?? null,
|
||||
);
|
||||
const pendingClarificationHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingClarification ?? null,
|
||||
);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(
|
||||
text: string,
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
) => {
|
||||
void sendMessage(text, attachments, contexts);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelStream(conversationId);
|
||||
}, [cancelStream, conversationId]);
|
||||
|
||||
const messages = conversation?.messages ?? [];
|
||||
const showDisclaimer = messages.length > 0;
|
||||
const inputDisabled =
|
||||
isStreamingHere ||
|
||||
isLoadingThread ||
|
||||
Boolean(pendingApprovalHere) ||
|
||||
Boolean(pendingClarificationHere);
|
||||
|
||||
if (isLoadingThread && messages.length === 0) {
|
||||
return (
|
||||
<div className="ai-conversation">
|
||||
<div className="ai-conversation__loading">
|
||||
<LoaderCircle size={20} className="ai-history__spinner" />
|
||||
Loading conversation…
|
||||
</div>
|
||||
<div className="ai-conversation__input-wrapper">
|
||||
<ChatInput onSend={handleSend} disabled />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-conversation">
|
||||
<VirtualizedMessages
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
{showDisclaimer && (
|
||||
<div className="ai-conversation__disclaimer" role="note" aria-live="polite">
|
||||
SigNoz AI can make mistakes. Please double-check responses.
|
||||
</div>
|
||||
)}
|
||||
<div className="ai-conversation__input-wrapper">
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
disabled={inputDisabled}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
# Page-Aware AI Action System — Technical Design
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** AI Assistant
|
||||
**Created:** 2026-03-31
|
||||
**Scope:** Frontend — AI Assistant integration with page-specific actions
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Page-Aware AI Action System extends the AI Assistant so that it can understand what page the user is currently on, read the page's live state (active filters, time range, selected entities, etc.), and execute actions available on that page — all through a natural-language conversation.
|
||||
|
||||
### Goals
|
||||
|
||||
- Let users query, filter, and navigate each SigNoz page by talking to the AI
|
||||
- Let users create and modify entities (dashboards, alerts, saved views) via the AI
|
||||
- Keep page-specific wiring isolated and co-located with each page — not inside the AI core
|
||||
- Zero-friction adoption: adding AI support to a new page is a single `usePageActions(...)` call
|
||||
- Prevent the AI from silently mutating state — every action requires explicit user confirmation
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Backend AI model training or fine-tuning
|
||||
- Real-time data streaming inside the AI chat (charts already handle that via existing blocks)
|
||||
- Cross-session memory of user preferences (deferred to a future persistent-context system)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Active Page (e.g. LogsExplorer) │ │
|
||||
│ │ │ │
|
||||
│ │ usePageActions('logs-explorer', [...actions]) │ │
|
||||
│ │ │ registers on mount │ │
|
||||
│ │ │ unregisters on unmount │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌──────────────────┐ │ │
|
||||
│ │ │ PageActionRegistry│ ◄── singleton │ │
|
||||
│ │ │ Map<id, Action> │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ └───────────┼─────────────────────────────────────┘ │
|
||||
│ │ getAll() + context snapshot │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ AI Assistant (Drawer / Full-page) │ │
|
||||
│ │ │ │
|
||||
│ │ sendMessage() │ │
|
||||
│ │ ├── builds [PAGE_CONTEXT] block from registry │ │
|
||||
│ │ ├── appends user text │ │
|
||||
│ │ └── sends to API ──────────────────────────────────► │ │
|
||||
│ │ AI Backend / Mock │ │
|
||||
│ │ ◄── streaming response │ │
|
||||
│ │ │ │
|
||||
│ │ MessageBubble │ │
|
||||
│ │ └── RichCodeBlock detects ```ai-action │ │
|
||||
│ │ └── ActionBlock │ │
|
||||
│ │ ├── renders description + param preview │ │
|
||||
│ │ ├── Accept → PageActionRegistry.execute() │ │
|
||||
│ │ └── Reject → no-op │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 `PageAction<TParams>`
|
||||
|
||||
The descriptor for a single action a page exposes to the AI.
|
||||
|
||||
```typescript
|
||||
interface PageAction<TParams = Record<string, unknown>> {
|
||||
/**
|
||||
* Stable identifier, dot-namespaced by page.
|
||||
* e.g. "logs.runQuery", "dashboard.createPanel", "alert.save"
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Natural-language description sent verbatim in the page context block.
|
||||
* The AI uses this to decide which action to invoke.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* JSON Schema (draft-07) describing the parameters this action accepts.
|
||||
* Sent to the AI so it can generate structurally valid calls.
|
||||
*/
|
||||
parameters: JSONSchemaObject;
|
||||
|
||||
/**
|
||||
* Performs the action. Resolves with a result the AI can narrate back to
|
||||
* the user. Rejects if the action cannot be completed.
|
||||
*/
|
||||
execute: (params: TParams) => Promise<ActionResult>;
|
||||
|
||||
/**
|
||||
* Optional snapshot of the current page state.
|
||||
* Called at message-send time so the AI has fresh context.
|
||||
* Return value is JSON-serialised into the [PAGE_CONTEXT] block.
|
||||
*/
|
||||
getContext?: () => unknown;
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
/** Short human-readable outcome: "Query updated with 2 new filters." */
|
||||
summary: string;
|
||||
/** Optional structured data the AI block can display (e.g. a new URL) */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type JSONSchemaObject = {
|
||||
type: 'object';
|
||||
properties: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 `PageActionDescriptor`
|
||||
|
||||
A lightweight, serialisable version of `PageAction` — safe to include in the API payload (no function references).
|
||||
|
||||
```typescript
|
||||
interface PageActionDescriptor {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
context?: unknown; // snapshot from getContext()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `AIActionBlock`
|
||||
|
||||
The JSON payload the AI emits inside an ` ```ai-action ``` ` fenced block when it wants to invoke an action.
|
||||
|
||||
```typescript
|
||||
interface AIActionBlock {
|
||||
/** Must match a registered PageAction.id */
|
||||
actionId: string;
|
||||
|
||||
/**
|
||||
* One-sentence explanation of what the action will do.
|
||||
* Displayed in the confirmation card.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Parameters the AI chose for this action.
|
||||
* Validated against the action's JSON Schema before execute() is called.
|
||||
*/
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. PageActionRegistry
|
||||
|
||||
A module-level singleton (like `BlockRegistry`). Keeps a flat `Map<id, PageAction>` so look-up is O(1). Supports batch register/unregister keyed by a `pageId` so a page can remove all its actions at once on unmount.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/pageActions/PageActionRegistry.ts
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
const PageActionRegistry = {
|
||||
/** Register a set of actions under a page scope key. */
|
||||
register(pageId: string, actions: PageAction[]): void;
|
||||
|
||||
/** Remove all actions registered under a page scope key. */
|
||||
unregister(pageId: string): void;
|
||||
|
||||
/** Look up a single action by its dot-namespaced id. */
|
||||
get(actionId: string): PageAction | undefined;
|
||||
|
||||
/**
|
||||
* Return serialisable descriptors for all currently registered actions,
|
||||
* with context snapshots already collected.
|
||||
*/
|
||||
snapshot(): PageActionDescriptor[];
|
||||
};
|
||||
```
|
||||
|
||||
### Internal structure
|
||||
|
||||
```typescript
|
||||
// pageId → action[] (for clean unregister)
|
||||
const _byPage = new Map<string, PageAction[]>();
|
||||
|
||||
// actionId → action (for O(1) lookup at execute time)
|
||||
const _byId = new Map<string, PageAction>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `usePageActions` Hook
|
||||
|
||||
Pages call this hook to register their actions declaratively. React lifecycle handles cleanup.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/pageActions/usePageActions.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
function usePageActions(pageId: string, actions: PageAction[]): void {
|
||||
useEffect(() => {
|
||||
PageActionRegistry.register(pageId, actions);
|
||||
return () => PageActionRegistry.unregister(pageId);
|
||||
// Re-register if action definitions change (e.g. new callbacks after query update)
|
||||
}, [pageId, actions]);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** action factories (see §8) memoize with `useMemo` so that the `actions` array reference is stable — preventing unnecessary re-registrations.
|
||||
|
||||
---
|
||||
|
||||
## 6. Context Injection in `sendMessage`
|
||||
|
||||
Before every outgoing message, the AI store reads the registry and prepends a machine-readable context block to the API payload content. This block is **never stored in the conversation** (not visible in the message list) — it exists only in the network payload.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
page: logs-explorer
|
||||
actions:
|
||||
- id: logs.runQuery
|
||||
description: "Run the current log query with updated filters or time range"
|
||||
params: { filters: TagFilter[], timeRange?: string }
|
||||
- id: logs.saveView
|
||||
description: "Save the current query as a named view"
|
||||
params: { name: string }
|
||||
state:
|
||||
filters: [{ key: "level", op: "=", value: "error" }]
|
||||
timeRange: "Last 15 minutes"
|
||||
panelType: "list"
|
||||
[/PAGE_CONTEXT]
|
||||
|
||||
{user's message}
|
||||
```
|
||||
|
||||
### Implementation in `useAIAssistantStore.sendMessage`
|
||||
|
||||
```typescript
|
||||
// Build context prefix from registry
|
||||
function buildContextPrefix(): string {
|
||||
const descriptors = PageActionRegistry.snapshot();
|
||||
if (descriptors.length === 0) return '';
|
||||
|
||||
const actionLines = descriptors.map(a =>
|
||||
` - id: ${a.id}\n description: "${a.description}"\n params: ${JSON.stringify(a.parameters.properties)}`
|
||||
).join('\n');
|
||||
|
||||
const contextLines = descriptors
|
||||
.filter(a => a.context !== undefined)
|
||||
.map(a => ` ${a.id}: ${JSON.stringify(a.context)}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'[PAGE_CONTEXT]',
|
||||
'actions:',
|
||||
actionLines,
|
||||
contextLines ? 'state:' : '',
|
||||
contextLines,
|
||||
'[/PAGE_CONTEXT]',
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// In sendMessage, when building the API payload:
|
||||
const payload = {
|
||||
conversationId: activeConversationId,
|
||||
messages: history.map((m, i) => {
|
||||
const content = (i === history.length - 1 && m.role === 'user')
|
||||
? buildContextPrefix() + m.content
|
||||
: m.content;
|
||||
return { role: m.role, content };
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
The displayed message in the UI always shows only `m.content` (the user's raw text). The context prefix only exists in the wire payload.
|
||||
|
||||
---
|
||||
|
||||
## 7. `ActionBlock` Component
|
||||
|
||||
Registered as `BlockRegistry.register('action', ActionBlock)`.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/components/blocks/ActionBlock.tsx
|
||||
```
|
||||
|
||||
### Render states
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ⚡ Suggested Action │
|
||||
│ │
|
||||
│ "Filter logs for ERROR level from payment-service" │
|
||||
│ │
|
||||
│ Parameters: │
|
||||
│ • level = ERROR │
|
||||
│ • service.name = payment-service │
|
||||
│ │
|
||||
│ [ Apply ] [ Dismiss ] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
── After Apply ──
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ✓ Applied: "Filter logs for ERROR level from │
|
||||
│ payment-service" │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
── After error ──
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ✗ Failed: "Action 'logs.runQuery' is not │
|
||||
│ available on the current page." │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Execution flow
|
||||
|
||||
1. Parse `AIActionBlock` JSON from the fenced block content
|
||||
2. Validate `parameters` against the action's JSON Schema (fail fast with a clear error)
|
||||
3. Look up `PageActionRegistry.get(actionId)` — if missing, show "not available" state
|
||||
4. On Accept: call `action.execute(parameters)`, show loading spinner
|
||||
5. On success: show summary from `ActionResult.summary`, call `markBlockAnswered(messageId, 'applied')`
|
||||
6. On failure: show error, allow retry
|
||||
7. On Dismiss: call `markBlockAnswered(messageId, 'dismissed')`
|
||||
|
||||
Like `ConfirmBlock` and `InteractiveQuestion`, `ActionBlock` uses `MessageContext` to get `messageId` and `answeredBlocks` from the store to persist its answered state across remounts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Page Action Factories
|
||||
|
||||
Each page co-locates its action definitions in an `aiActions.ts` file. Factories are functions that close over the page's live state and handlers, so the `execute` callbacks always operate on current data.
|
||||
|
||||
### Example: `src/pages/LogsExplorer/aiActions.ts`
|
||||
|
||||
```typescript
|
||||
export function logsRunQueryAction(deps: {
|
||||
handleRunQuery: () => void;
|
||||
updateQueryFilters: (filters: TagFilterItem[]) => void;
|
||||
currentQuery: Query;
|
||||
globalTime: GlobalReducer;
|
||||
}): PageAction {
|
||||
return {
|
||||
id: 'logs.runQuery',
|
||||
description: 'Update the active log filters and run the query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Replacement filter list. Each item has key, op, value.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
op: { type: 'string', enum: ['=', '!=', 'IN', 'NOT_IN', 'CONTAINS', 'NOT_CONTAINS'] },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async ({ filters }) => {
|
||||
deps.updateQueryFilters(filters as TagFilterItem[]);
|
||||
deps.handleRunQuery();
|
||||
return { summary: `Query updated with ${filters.length} filter(s) and re-run.` };
|
||||
},
|
||||
getContext: () => ({
|
||||
filters: deps.currentQuery.builder.queryData[0]?.filters?.items ?? [],
|
||||
timeRange: deps.globalTime.selectedTime,
|
||||
panelType: 'list',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function logsSaveViewAction(deps: {
|
||||
saveView: (name: string) => Promise<void>;
|
||||
}): PageAction {
|
||||
return {
|
||||
id: 'logs.saveView',
|
||||
description: 'Save the current log query as a named view',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', description: 'View name' } },
|
||||
required: ['name'],
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
await deps.saveView(name as string);
|
||||
return { summary: `View "${name}" saved.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in the page component
|
||||
|
||||
```typescript
|
||||
// src/pages/LogsExplorer/index.tsx
|
||||
|
||||
const { handleRunQuery, updateQueryFilters, currentQuery } = useQueryBuilder();
|
||||
const globalTime = useSelector((s) => s.globalTime);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
logsRunQueryAction({ handleRunQuery, updateQueryFilters, currentQuery, globalTime }),
|
||||
logsSaveViewAction({ saveView }),
|
||||
logsExportToDashboardAction({ exportToDashboard }),
|
||||
],
|
||||
[handleRunQuery, updateQueryFilters, currentQuery, globalTime, saveView, exportToDashboard],
|
||||
);
|
||||
|
||||
usePageActions('logs-explorer', actions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Action Catalogue (Initial Scope)
|
||||
|
||||
### 9.1 Logs Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `logs.runQuery` | Update filters and run the log query | `filters: TagFilterItem[]` |
|
||||
| `logs.addFilter` | Append a single filter to the existing set | `key, op, value` |
|
||||
| `logs.changeView` | Switch between list / timeseries / table | `view: 'list' \| 'timeseries' \| 'table'` |
|
||||
| `logs.saveView` | Save current query as a named view | `name: string` |
|
||||
| `logs.exportToDashboard` | Add current query as a panel to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
### 9.2 Traces Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `traces.runQuery` | Update filters and run the trace query | `filters: TagFilterItem[]` |
|
||||
| `traces.addFilter` | Append a single filter | `key, op, value` |
|
||||
| `traces.changeView` | Switch between list / trace / timeseries / table | `view: string` |
|
||||
| `traces.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
### 9.3 Dashboards List
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `dashboards.create` | Create a new blank dashboard | `title: string, description?: string` |
|
||||
| `dashboards.search` | Filter the dashboard list | `query: string` |
|
||||
| `dashboards.duplicate` | Duplicate an existing dashboard | `dashboardId: string, newTitle?: string` |
|
||||
| `dashboards.delete` | Delete a dashboard (requires confirmation) | `dashboardId: string` |
|
||||
|
||||
### 9.4 Dashboard Detail
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `dashboard.createPanel` | Add a new panel to the current dashboard | `title: string, queryType: 'logs'\|'metrics'\|'traces'` |
|
||||
| `dashboard.rename` | Rename the current dashboard | `title: string` |
|
||||
| `dashboard.deletePanel` | Remove a panel | `panelId: string` |
|
||||
| `dashboard.addVariable` | Add a dashboard-level variable | `name: string, type: string, defaultValue?: string` |
|
||||
|
||||
### 9.5 Alerts
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `alerts.navigateCreate` | Navigate to the Create Alert page | `alertType?: 'metrics'\|'logs'\|'traces'` |
|
||||
| `alerts.disable` | Disable an existing alert rule | `alertId: string` |
|
||||
| `alerts.enable` | Enable an existing alert rule | `alertId: string` |
|
||||
| `alerts.delete` | Delete an alert rule | `alertId: string` |
|
||||
|
||||
### 9.6 Create Alert
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `alert.setCondition` | Set the alert threshold condition | `op: string, threshold: number` |
|
||||
| `alert.test` | Test the alert rule against live data | — |
|
||||
| `alert.save` | Save the alert rule | `name: string, severity?: string` |
|
||||
|
||||
### 9.7 Metrics Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `metrics.runQuery` | Run a metric query | `metric: string, aggregation?: string` |
|
||||
| `metrics.saveView` | Save current query as a view | `name: string` |
|
||||
| `metrics.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Context Block Schema (Wire Format)
|
||||
|
||||
The `[PAGE_CONTEXT]` block is a freeform text section prepended to the API message content. For the real backend, this should migrate to a structured `system` role message or a dedicated field in the request body. For the initial implementation, embedding it in the user message content is sufficient and works with any LLM API.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
page: <pageId>
|
||||
actions:
|
||||
- id: <actionId>
|
||||
description: "<description>"
|
||||
params: <JSON Schema properties summary>
|
||||
...
|
||||
state:
|
||||
<actionId>: <JSON context snapshot>
|
||||
...
|
||||
[/PAGE_CONTEXT]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Parameter Validation
|
||||
|
||||
Before `execute()` is called, parameters are validated client-side using the JSON Schema stored on the `PageAction`. This catches cases where the AI generates structurally wrong parameters.
|
||||
|
||||
```typescript
|
||||
function validateParams(schema: JSONSchemaObject, params: unknown): string | null {
|
||||
// Minimal validation: check required fields are present and types match
|
||||
// Full implementation can use ajv or a lightweight equivalent
|
||||
for (const key of schema.required ?? []) {
|
||||
if (!(params as Record<string, unknown>)[key]) {
|
||||
return `Missing required parameter: "${key}"`;
|
||||
}
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
```
|
||||
|
||||
If validation fails, `ActionBlock` shows the error inline and does not call `execute`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Answered State Persistence
|
||||
|
||||
`ActionBlock` follows the same pattern as `ConfirmBlock` and `InteractiveQuestion`:
|
||||
|
||||
- Uses `MessageContext` to read `messageId`
|
||||
- Reads `answeredBlocks[messageId]` from the Zustand store
|
||||
- On Accept: calls `markBlockAnswered(messageId, 'applied')`
|
||||
- On Dismiss: calls `markBlockAnswered(messageId, 'dismissed')`
|
||||
- On Error: calls `markBlockAnswered(messageId, 'error:<message>')`
|
||||
|
||||
This ensures re-renders and re-mounts do not reset the block to its initial state.
|
||||
|
||||
---
|
||||
|
||||
## 13. Error Handling
|
||||
|
||||
| Failure scenario | Behaviour |
|
||||
|-----------------|-----------|
|
||||
| `actionId` not in registry (page navigated away) | Block shows: "This action is no longer available — navigate back to \<page\> and try again." |
|
||||
| Parameter validation fails | Block shows the validation error inline; does not call `execute` |
|
||||
| `execute()` throws | Block shows the error message; offers a Retry button |
|
||||
| AI emits malformed JSON in the block | `RichCodeBlock` falls back to rendering the raw fenced block as a code block |
|
||||
| User navigates away mid-execution | `execute()` promise resolves/rejects normally; result is stored in `answeredBlocks` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Permissions
|
||||
|
||||
Many page actions map to protected operations (e.g., creating a dashboard, deleting an alert). Each action factory should check the relevant permission before registering — if the user doesn't have permission, the action is simply not registered and will not appear in the context block.
|
||||
|
||||
```typescript
|
||||
const canCreateDashboard = useComponentPermission(['create_new_dashboards']);
|
||||
|
||||
const actions = useMemo(() => [
|
||||
...(canCreateDashboard ? [dashboardCreateAction({ ... })] : []),
|
||||
// ...
|
||||
], [canCreateDashboard, ...]);
|
||||
|
||||
usePageActions('dashboard-list', actions);
|
||||
```
|
||||
|
||||
This way the AI never suggests actions the user cannot perform.
|
||||
|
||||
---
|
||||
|
||||
## 15. Implementation Plan
|
||||
|
||||
### Phase 1 — Infrastructure (no page integrations yet)
|
||||
|
||||
1. `src/container/AIAssistant/pageActions/types.ts`
|
||||
2. `src/container/AIAssistant/pageActions/PageActionRegistry.ts`
|
||||
3. `src/container/AIAssistant/pageActions/usePageActions.ts`
|
||||
4. `ActionBlock.tsx` + `ActionBlock.scss`
|
||||
5. Register `'action'` in `blocks/index.ts`
|
||||
6. Context injection in `useAIAssistantStore.sendMessage`
|
||||
7. Mock API support for `[PAGE_CONTEXT]` → responds with `ai-action` block
|
||||
|
||||
### Phase 2 — Logs Explorer integration
|
||||
|
||||
8. `src/pages/LogsExplorer/aiActions.ts` (factories for `logs.*` actions)
|
||||
9. Wire `usePageActions` into `LogsExplorer/index.tsx`
|
||||
|
||||
### Phase 3 — Traces, Dashboards, Alerts
|
||||
|
||||
10. `src/pages/TracesExplorer/aiActions.ts`
|
||||
11. `src/pages/DashboardsListPage/aiActions.ts`
|
||||
12. `src/pages/DashboardPage/aiActions.ts`
|
||||
13. `src/pages/AlertList/aiActions.ts`
|
||||
14. `src/pages/CreateAlert/aiActions.ts`
|
||||
|
||||
### Phase 4 — Backend handoff
|
||||
|
||||
15. Move `[PAGE_CONTEXT]` from content-embedded text to a dedicated `pageContext` field in the API request body
|
||||
16. Replace mock responses with real AI backend calls
|
||||
|
||||
---
|
||||
|
||||
## 16. Open Questions
|
||||
|
||||
| # | Question | Impact |
|
||||
|---|----------|--------|
|
||||
| 1 | Should `ActionBlock` require a single user confirmation, or show a diff-style preview of what will change? | UX complexity |
|
||||
| 2 | How should multi-step actions work? (e.g. "create dashboard then add three panels") — queue them or chain them? | Architecture |
|
||||
| 3 | Should the registry support a global `getContext()` for page-agnostic state (user, org, time range)? | Context completeness |
|
||||
| 4 | What is the max context block size before it degrades AI quality? | Prompt engineering |
|
||||
| 5 | Should failed actions add a retry message back into the conversation, or stay silent? | UX |
|
||||
| 6 | Can two pages be active simultaneously (e.g. drawer open over dashboard)? How do we prioritise which actions are "active"? | Edge case |
|
||||
|
||||
---
|
||||
|
||||
## 17. Relation to Existing AI Architecture
|
||||
|
||||
```
|
||||
BlockRegistry PageActionRegistry
|
||||
│ │
|
||||
│ render blocks │ register/unregister actions
|
||||
│ (ai-chart, ai- │ (logs.runQuery, dashboard.create...)
|
||||
│ question, ai- │
|
||||
│ confirm, ...) │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
MessageBubble / StreamingMessage
|
||||
│
|
||||
RichCodeBlock (routes to BlockRegistry)
|
||||
│
|
||||
ActionBlock ←── new: reads PageActionRegistry to execute
|
||||
```
|
||||
|
||||
The `PageActionRegistry` is a parallel singleton to `BlockRegistry`. `BlockRegistry` maps `fenced-block-type → render component`. `PageActionRegistry` maps `action-id → execute function`. `ActionBlock` bridges the two: it is a registered *block* (render side) that calls into the *action* registry (execution side).
|
||||
@@ -1,911 +0,0 @@
|
||||
# AI Assistant — Technical Design Document
|
||||
|
||||
**Status:** In Progress
|
||||
**Last Updated:** 2026-04-01
|
||||
**Scope:** Frontend AI Assistant subsystem — UI, state management, API integration, page action system
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Architecture Diagram](#2-architecture-diagram)
|
||||
3. [User Flows](#3-user-flows)
|
||||
4. [UI Modes and Transitions](#4-ui-modes-and-transitions)
|
||||
5. [Control Flow: UI → API → UI](#5-control-flow-ui--api--ui)
|
||||
6. [SSE Response Handling](#6-sse-response-handling)
|
||||
7. [Page Action System](#7-page-action-system)
|
||||
8. [Block Rendering System](#8-block-rendering-system)
|
||||
9. [State Management](#9-state-management)
|
||||
10. [Page-Specific Actions](#10-page-specific-actions)
|
||||
11. [Voice Input](#11-voice-input)
|
||||
12. [File Attachments](#12-file-attachments)
|
||||
13. [Development Mode (Mock)](#13-development-mode-mock)
|
||||
14. [Adding a New Page's Actions](#14-adding-a-new-pages-actions)
|
||||
15. [Adding a New Block Type](#15-adding-a-new-block-type)
|
||||
16. [Data Contracts](#16-data-contracts)
|
||||
17. [Key Design Decisions](#17-key-design-decisions)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The AI Assistant is an embedded chat interface inside SigNoz that understands the current page context and can execute actions on behalf of the user (e.g., filter logs, update queries, navigate views). It communicates with a backend AI service via Server-Sent Events (SSE) and renders structured responses as rich interactive blocks alongside plain markdown.
|
||||
|
||||
**Key goals:**
|
||||
- **Context-aware:** the AI always knows what page the user is on and what actions are available
|
||||
- **Streaming:** responses appear token-by-token, no waiting for a full response
|
||||
- **Actionable:** the AI can trigger page mutations (filter logs, switch views) without copy-paste
|
||||
- **Extensible:** new pages can register actions; new block types can be added independently
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ User │
|
||||
└────────────────────────────┬────────────────────────────────────────┘
|
||||
│ types text / voice / file
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ Panel │ │ Modal │ │ Full-Screen Page │ │
|
||||
│ │ (drawer) │ │ (Cmd+P) │ │ /ai-assistant/:id │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └─────────────┬─────────────┘ │
|
||||
│ └────────────────┴─────────────────────────┘ │
|
||||
│ │ all modes share │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ConversationView│ │
|
||||
│ │ + ChatInput │ │
|
||||
│ └──────┬──────┘ │
|
||||
└───────────────────────────┼─────────────────────────────────────────┘
|
||||
│ sendMessage()
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Zustand Store (useAIAssistantStore) │
|
||||
│ │
|
||||
│ conversations{} isStreaming │
|
||||
│ activeConversationId streamingContent │
|
||||
│ isDrawerOpen answeredBlocks{} │
|
||||
│ isModalOpen │
|
||||
│ │
|
||||
│ sendMessage() │
|
||||
│ 1. push user message │
|
||||
│ 2. buildContextPrefix() ──► PageActionRegistry.snapshot() │
|
||||
│ 3. call streamChat(payload) [or mockStreamChat in dev] │
|
||||
│ 4. accumulate chunks into streamingContent │
|
||||
│ 5. on done: push assistant message with actions[] │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ POST /api/v1/assistant/threads
|
||||
│ (SSE response)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ API Layer (src/api/ai/chat.ts) │
|
||||
│ │
|
||||
│ streamChat(payload) → AsyncGenerator<SSEEvent> │
|
||||
│ Parses data: {...}\n\n SSE frames │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Page Action System │
|
||||
│ │
|
||||
│ PageActionRegistry ◄──── usePageActions() hook │
|
||||
│ (module singleton) (called by each page on mount) │
|
||||
│ │
|
||||
│ Registry is read by buildContextPrefix() before every API call. │
|
||||
│ │
|
||||
│ AI response → ai-action block → ActionBlock component │
|
||||
│ → PageActionRegistry.get(actionId).execute(params) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. User Flows
|
||||
|
||||
### 3.1 Basic Chat
|
||||
|
||||
```
|
||||
User opens panel (header icon / Cmd+P / trigger button)
|
||||
→ Conversation created (or resumed from store)
|
||||
→ ChatInput focused automatically
|
||||
|
||||
User types message → presses Enter
|
||||
→ User message appended to conversation
|
||||
→ StreamingMessage (typing indicator) appears
|
||||
→ SSE stream opens: tokens arrive word-by-word
|
||||
→ StreamingMessage renders live content
|
||||
→ Stream ends: StreamingMessage replaced by MessageBubble
|
||||
→ Follow-up actions (if any) shown as chips on the message
|
||||
```
|
||||
|
||||
### 3.2 AI Applies a Page Action (autoApply)
|
||||
|
||||
```
|
||||
User: "Filter logs for errors from payment-svc"
|
||||
→ PAGE_CONTEXT injected into wire payload
|
||||
(includes registered action schemas + current query state)
|
||||
→ AI responds with plain text + ai-action block
|
||||
→ ActionBlock mounts with autoApply=true
|
||||
→ execute() fires immediately on mount — no user confirmation
|
||||
→ Logs Explorer query updated via redirectWithQueryBuilderData()
|
||||
→ URL reflects new filters, QueryBuilderV2 UI updates
|
||||
→ ActionBlock shows "Applied ✓" state (persisted in answeredBlocks)
|
||||
```
|
||||
|
||||
### 3.3 AI Asks a Clarifying Question
|
||||
|
||||
```
|
||||
AI responds with ai-question block
|
||||
→ InteractiveQuestion renders (radio or checkbox)
|
||||
→ User selects answer → sendMessage() called automatically
|
||||
→ Answer persisted in answeredBlocks (survives re-renders / mode switches)
|
||||
→ Block shows answered state on re-mount
|
||||
```
|
||||
|
||||
### 3.4 AI Requests Confirmation
|
||||
|
||||
```
|
||||
AI responds with ai-confirm block
|
||||
→ ConfirmBlock renders Accept / Reject buttons
|
||||
→ Accept → sendMessage(acceptText)
|
||||
→ Reject → sendMessage(rejectText)
|
||||
→ Block shows answered state, buttons disabled
|
||||
```
|
||||
|
||||
### 3.5 Modal → Panel Minimize
|
||||
|
||||
```
|
||||
User opens modal (Cmd+P), interacts with AI
|
||||
User clicks minimize button (−)
|
||||
→ minimizeModal(): isModalOpen=false, isDrawerOpen=true (atomic)
|
||||
→ Same conversation continues in the side panel
|
||||
→ No data loss, streaming state preserved
|
||||
```
|
||||
|
||||
### 3.6 Panel → Full Screen Expand
|
||||
|
||||
```
|
||||
User clicks Maximize in panel header
|
||||
→ closeDrawer() called
|
||||
→ navigate to /ai-assistant/:conversationId
|
||||
→ Full-screen page renders same conversation
|
||||
→ TopNav (timepicker header) hidden on this route
|
||||
→ SideNav remains visible
|
||||
```
|
||||
|
||||
### 3.7 Voice Input
|
||||
|
||||
```
|
||||
User clicks mic button in ChatInput
|
||||
→ SpeechRecognition.start()
|
||||
→ isListening=true (mic turns red, CSS pulse animation)
|
||||
→ Interim results: textarea updates live as user speaks
|
||||
→ Recognition ends (auto pause detection or manual click)
|
||||
→ Final transcript committed to committedTextRef
|
||||
→ User reviews / edits text, then sends normally
|
||||
```
|
||||
|
||||
### 3.8 Resize Panel
|
||||
|
||||
```
|
||||
User hovers over left edge of panel
|
||||
→ Resize handle highlights (purple line)
|
||||
User drags left/right
|
||||
→ panel width updates live (min 380px, max 800px)
|
||||
→ document.body.cursor = 'col-resize' during drag
|
||||
→ text selection disabled during drag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Modes and Transitions
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ All Closed │
|
||||
│ (trigger shown) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
Click trigger Cmd+P navigate to
|
||||
│ │ /ai-assistant/:id
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ Panel │ │ Modal │ │ Full-Screen │
|
||||
│ (drawer) │ │ (portal) │ │ Page │
|
||||
└────┬─────┘ └────┬─────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
┌────▼──────────────▼───────────────▼────┐
|
||||
│ ConversationView (shared component) │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Transitions:
|
||||
Panel → Full-Screen : Maximize → closeDrawer() + history.push()
|
||||
Modal → Panel : Minimize → minimizeModal()
|
||||
Modal → Full-Screen : Maximize → closeModal() + history.push()
|
||||
Any → Closed : X button or Escape key
|
||||
|
||||
Visibility rules:
|
||||
Header AI icon : hidden when isDrawerOpen=true
|
||||
Trigger button : hidden when isDrawerOpen || isModalOpen || isFullScreenPage
|
||||
TopNav (timepicker): hidden when pathname.startsWith('/ai-assistant/')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Control Flow: UI → API → UI
|
||||
|
||||
### 5.1 Message Send
|
||||
|
||||
```
|
||||
ChatInput.handleSend()
|
||||
├── setText('') // clear textarea
|
||||
├── committedTextRef.current = '' // clear voice accumulator
|
||||
└── store.sendMessage(text, attachments)
|
||||
│
|
||||
├── Push userMessage to conversations[id].messages
|
||||
├── set isStreaming=true, streamingContent=''
|
||||
│
|
||||
├── buildContextPrefix()
|
||||
│ └── PageActionRegistry.snapshot()
|
||||
│ → returns PageActionDescriptor[] (ids, schemas, current context)
|
||||
│ → serialized as [PAGE_CONTEXT]...[/PAGE_CONTEXT] string
|
||||
│
|
||||
├── Build wire payload:
|
||||
│ {
|
||||
│ conversationId,
|
||||
│ messages: history.map((m, i) => ({
|
||||
│ role: m.role,
|
||||
│ content: i === last && role==='user'
|
||||
│ ? contextPrefix + m.content // wire only, never stored
|
||||
│ : m.content
|
||||
│ }))
|
||||
│ }
|
||||
│
|
||||
├── for await (event of streamChat(payload)):
|
||||
│ ├── streamingContent += event.content // triggers StreamingMessage re-render
|
||||
│ └── if event.done: finalActions = event.actions; break
|
||||
│
|
||||
├── Push assistantMessage { id: serverMessageId, content, actions }
|
||||
└── set isStreaming=false, streamingContent=''
|
||||
```
|
||||
|
||||
### 5.2 Streaming Render Pipeline
|
||||
|
||||
```
|
||||
streamingContent (Zustand state)
|
||||
→ StreamingMessage component (rendered while isStreaming=true)
|
||||
→ react-markdown
|
||||
→ RichCodeBlock (custom code renderer)
|
||||
→ BlockRegistry.get(lang) → renders chart / table / action / etc.
|
||||
|
||||
On stream end:
|
||||
streamingContent → assistantMessage.content (frozen in store)
|
||||
StreamingMessage removed, MessageBubble added with same content
|
||||
MessageBubble renders through identical markdown pipeline
|
||||
```
|
||||
|
||||
### 5.3 PAGE_CONTEXT Wire Format
|
||||
|
||||
The context prefix is prepended to the last user message in the API payload **only**. It is never stored in the conversation or shown in the UI.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
actions:
|
||||
- id: logs.runQuery
|
||||
description: "Replace all log filters and re-run the query"
|
||||
params: {"filters": {"type": "array", "items": {...}}}
|
||||
- id: logs.addFilter
|
||||
description: "Append a single key/op/value filter"
|
||||
params: {"key": {...}, "op": {...}, "value": {...}}
|
||||
state:
|
||||
logs.runQuery: {"currentFilters": [...], "currentView": "list"}
|
||||
[/PAGE_CONTEXT]
|
||||
User's actual message text here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SSE Response Handling
|
||||
|
||||
### 6.1 Wire Format
|
||||
|
||||
**Request:**
|
||||
```
|
||||
POST /api/v1/assistant/threads
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"conversationId": "uuid",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "[PAGE_CONTEXT]...[/PAGE_CONTEXT]\nUser text" },
|
||||
{ "role": "assistant", "content": "Previous assistant turn" },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (SSE stream):**
|
||||
```
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"I'll ","done":false,"actions":[]}\n\n
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"update ","done":false,"actions":[]}\n\n
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"the query.","done":true,"actions":[
|
||||
{"id":"act-1","label":"Add another filter","kind":"follow_up","payload":{},"expiresAt":null}
|
||||
]}\n\n
|
||||
```
|
||||
|
||||
### 6.2 SSE Parsing (src/api/ai/chat.ts)
|
||||
|
||||
```
|
||||
fetch() → ReadableStream → TextDecoder → string chunks
|
||||
→ lineBuffer accumulates across chunks (handles partial lines)
|
||||
→ split on '\n\n' (SSE event boundary)
|
||||
→ for each complete part:
|
||||
find line starting with 'data: '
|
||||
strip prefix → parse JSON → yield SSEEvent
|
||||
→ '[DONE]' sentinel → stop iteration
|
||||
→ malformed JSON → skip silently
|
||||
→ finally: reader.releaseLock()
|
||||
```
|
||||
|
||||
### 6.3 Structured Content in the Stream
|
||||
|
||||
The AI embeds block payloads as markdown fenced code blocks with `ai-*` language tags inside the `content` stream. These are parsed live as tokens arrive:
|
||||
|
||||
````markdown
|
||||
Here are the results:
|
||||
|
||||
```ai-graph
|
||||
{
|
||||
"title": "p99 Latency",
|
||||
"datasets": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The spike started at 14:45.
|
||||
````
|
||||
|
||||
React-markdown renders the code block → `RichCodeBlock` detects the `ai-` prefix → looks up `BlockRegistry` → renders the chart/table/action component.
|
||||
|
||||
### 6.4 actions[] Array
|
||||
|
||||
Actions arrive on the **final** SSE event (`done: true`). They are stored on the `Message` object. Each action's `kind` determines UI behavior:
|
||||
|
||||
| Kind | Behavior |
|
||||
|---|---|
|
||||
| `follow_up` | Rendered as suggestion chip; click sends as new message |
|
||||
| `open_resource` | Opens a SigNoz resource (trace, log, dashboard) |
|
||||
| `navigate` | Navigates to a SigNoz route |
|
||||
| `apply_filter` | Directly triggers a registered page action |
|
||||
| `open_docs` | Opens a documentation URL |
|
||||
| `undo` | Reverts the last page mutation |
|
||||
| `revert` | Reverts to a specified previous state |
|
||||
|
||||
---
|
||||
|
||||
## 7. Page Action System
|
||||
|
||||
### 7.1 Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---|---|
|
||||
| `PageAction<TParams>` | An action a page exposes to the AI: id, description, JSON Schema params, `execute()`, optional `getContext()`, optional `autoApply` |
|
||||
| `PageActionRegistry` | Module-level singleton (`Map<pageId, actions[]>` + `Map<actionId, action>`) |
|
||||
| `usePageActions(pageId, actions)` | React hook — registers on mount, unregisters on unmount |
|
||||
| `PageActionDescriptor` | Serializable version of `PageAction` (no functions) — sent to AI via PAGE_CONTEXT |
|
||||
| `AIActionBlock` | Shape the AI emits when invoking an action: `{ actionId, description, parameters }` |
|
||||
|
||||
### 7.2 Lifecycle
|
||||
|
||||
```
|
||||
Page component mounts
|
||||
└── usePageActions('logs-explorer', actions)
|
||||
└── PageActionRegistry.register('logs-explorer', actions)
|
||||
→ added to _byPage map (for bulk unregister)
|
||||
→ added to _byId map (for O(1) lookup by actionId)
|
||||
|
||||
User sends any message
|
||||
└── buildContextPrefix()
|
||||
└── PageActionRegistry.snapshot()
|
||||
→ returns PageActionDescriptor[] with current context values
|
||||
|
||||
AI response contains ```ai-action block
|
||||
└── ActionBlock component mounts
|
||||
├── PageActionRegistry.get(actionId) → PageAction with execute()
|
||||
└── if autoApply: execute(params) on mount
|
||||
else: render confirmation card, execute on user click
|
||||
|
||||
Page component unmounts
|
||||
└── usePageActions cleanup
|
||||
└── PageActionRegistry.unregister('logs-explorer')
|
||||
→ all actions for this page removed from both maps
|
||||
```
|
||||
|
||||
### 7.3 ActionBlock State Machine
|
||||
|
||||
**autoApply: true** (fires immediately on mount):
|
||||
```
|
||||
mount
|
||||
→ hasFired ref guard (prevents double-fire in React StrictMode)
|
||||
→ PageActionRegistry.get(actionId).execute(params)
|
||||
→ render: loading spinner
|
||||
→ success: "Applied ✓" state, markBlockAnswered(messageId, 'applied')
|
||||
→ error: error state with message, markBlockAnswered(messageId, 'error:...')
|
||||
```
|
||||
|
||||
**autoApply: false** (user must confirm):
|
||||
```
|
||||
mount
|
||||
→ render: description + parameter summary + Apply / Dismiss buttons
|
||||
→ Apply clicked:
|
||||
→ execute(params) → loading → applied state
|
||||
→ markBlockAnswered(messageId, 'applied')
|
||||
→ Dismiss clicked:
|
||||
→ markBlockAnswered(messageId, 'dismissed')
|
||||
```
|
||||
|
||||
**Re-mount (scroll / mode switch):**
|
||||
```
|
||||
mount
|
||||
→ answeredBlocks[messageId] exists
|
||||
→ render answered state directly (skip pending UI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Block Rendering System
|
||||
|
||||
### 8.1 Registration
|
||||
|
||||
`src/container/AIAssistant/components/blocks/index.ts` registers all built-in types at import time (side-effect import):
|
||||
|
||||
```typescript
|
||||
BlockRegistry.register('action', ActionBlock);
|
||||
BlockRegistry.register('question', InteractiveQuestion);
|
||||
BlockRegistry.register('confirm', ConfirmBlock);
|
||||
BlockRegistry.register('timeseries', TimeseriesBlock);
|
||||
BlockRegistry.register('barchart', BarChartBlock);
|
||||
BlockRegistry.register('piechart', PieChartBlock);
|
||||
BlockRegistry.register('linechart', LineChartBlock);
|
||||
BlockRegistry.register('graph', LineChartBlock); // alias
|
||||
```
|
||||
|
||||
### 8.2 Render Pipeline
|
||||
|
||||
```
|
||||
MessageBubble (assistant message)
|
||||
└── react-markdown
|
||||
└── components={{ code: RichCodeBlock }}
|
||||
└── RichCodeBlock
|
||||
├── lang.startsWith('ai-') ?
|
||||
│ yes → BlockRegistry.get(lang.slice(3))
|
||||
│ → parse JSON content
|
||||
│ → render block component
|
||||
└── no → render plain <code> element
|
||||
```
|
||||
|
||||
### 8.3 Block Component Interface
|
||||
|
||||
All block components receive:
|
||||
```typescript
|
||||
interface BlockProps {
|
||||
content: string; // raw JSON string from the fenced code block body
|
||||
}
|
||||
```
|
||||
|
||||
Blocks access shared context via:
|
||||
```typescript
|
||||
const { messageId } = useContext(MessageContext); // for answeredBlocks key
|
||||
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore(s => s.sendMessage); // for interactive blocks
|
||||
```
|
||||
|
||||
### 8.4 Block Types Reference
|
||||
|
||||
| Tag | Component | Purpose |
|
||||
|---|---|---|
|
||||
| `ai-action` | `ActionBlock` | Invokes a registered page action |
|
||||
| `ai-question` | `InteractiveQuestion` | Radio or checkbox user selection |
|
||||
| `ai-confirm` | `ConfirmBlock` | Binary accept / reject prompt |
|
||||
| `ai-timeseries` | `TimeseriesBlock` | Tabular data with rows and columns |
|
||||
| `ai-barchart` | `BarChartBlock` | Horizontal / vertical bar chart |
|
||||
| `ai-piechart` | `PieChartBlock` | Doughnut / pie chart |
|
||||
| `ai-linechart` | `LineChartBlock` | Multi-series line chart |
|
||||
| `ai-graph` | `LineChartBlock` | Alias for `ai-linechart` |
|
||||
|
||||
---
|
||||
|
||||
## 9. State Management
|
||||
|
||||
### 9.1 Store Shape (Zustand + Immer)
|
||||
|
||||
```typescript
|
||||
interface AIAssistantStore {
|
||||
// UI
|
||||
isDrawerOpen: boolean;
|
||||
isModalOpen: boolean;
|
||||
activeConversationId: string | null;
|
||||
|
||||
// Data
|
||||
conversations: Record<string, Conversation>;
|
||||
|
||||
// Streaming
|
||||
streamingContent: string; // accumulates token-by-token during SSE stream
|
||||
isStreaming: boolean;
|
||||
|
||||
// Block answer persistence
|
||||
answeredBlocks: Record<string, string>; // messageId → answer string
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Conversation Structure
|
||||
|
||||
```typescript
|
||||
interface Conversation {
|
||||
id: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
title?: string; // auto-derived from first user message (60 char max)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string; // server messageId for assistant turns, uuidv4 for user
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
attachments?: MessageAttachment[];
|
||||
actions?: AssistantAction[]; // follow-up actions, present on final assistant message only
|
||||
createdAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Streaming State Machine
|
||||
|
||||
```
|
||||
idle
|
||||
→ sendMessage() called
|
||||
→ isStreaming=true, streamingContent=''
|
||||
|
||||
streaming
|
||||
→ each SSE chunk: streamingContent += event.content (triggers StreamingMessage re-render)
|
||||
→ done event: isStreaming=false, streamingContent=''
|
||||
→ assistant message pushed to conversation
|
||||
|
||||
idle (settled)
|
||||
→ MessageBubble renders final frozen content
|
||||
→ ChatInput re-enabled (disabled={isStreaming})
|
||||
```
|
||||
|
||||
### 9.4 Answered Block Persistence
|
||||
|
||||
Interactive blocks call `markBlockAnswered(messageId, answer)` on completion. On re-mount, blocks check `answeredBlocks[messageId]` and render the answered state directly. This ensures:
|
||||
- Scrolling away and back does not reset blocks
|
||||
- Switching UI modes (panel → full-screen) does not reset blocks
|
||||
- Blocks cannot be answered twice
|
||||
|
||||
---
|
||||
|
||||
## 10. Page-Specific Actions
|
||||
|
||||
### 10.1 Logs Explorer
|
||||
|
||||
**File:** `src/pages/LogsExplorer/aiActions.ts`
|
||||
**Registered in:** `src/pages/LogsExplorer/index.tsx` via `usePageActions('logs-explorer', aiActions)`
|
||||
|
||||
| Action ID | autoApply | Description |
|
||||
|---|---|---|
|
||||
| `logs.runQuery` | `true` | Replace all filters in the query builder and re-run |
|
||||
| `logs.addFilter` | `true` | Append a single `key / op / value` filter |
|
||||
| `logs.changeView` | `true` | Switch between list / timeseries / table views |
|
||||
| `logs.saveView` | `false` | Save current query as a named view (requires confirmation) |
|
||||
|
||||
**Critical implementation detail:** All query mutations use `redirectWithQueryBuilderData()`, not `handleSetQueryData`. The Logs Explorer's `QueryBuilderV2` is URL-driven — `compositeQuery` in the URL is the source of truth for displayed filters. `handleSetQueryData` updates React state only; `redirectWithQueryBuilderData` syncs the URL, making changes visible in the UI.
|
||||
|
||||
**Context shape provided to AI:**
|
||||
```typescript
|
||||
getContext: () => ({
|
||||
currentFilters: currentQuery.builder.queryData[0].filters.items,
|
||||
currentView: currentView, // 'list' | 'timeseries' | 'table'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Voice Input
|
||||
|
||||
### 11.1 Hook: useSpeechRecognition
|
||||
|
||||
**File:** `src/container/AIAssistant/hooks/useSpeechRecognition.ts`
|
||||
|
||||
```typescript
|
||||
const { isListening, isSupported, start, stop, transcript, isFinal } =
|
||||
useSpeechRecognition({ lang: 'en-US', onError });
|
||||
```
|
||||
|
||||
Exposes `transcript` and `isFinal` as React state (not callbacks) so `ChatInput` reacts via `useEffect([transcript, isFinal])`, eliminating stale closure issues.
|
||||
|
||||
### 11.2 Interim vs Final Handling
|
||||
|
||||
```
|
||||
onresult (isFinal=false) → pendingInterim = text → setTranscript(text), setIsFinal(false)
|
||||
onresult (isFinal=true) → pendingInterim = '' → setTranscript(text), setIsFinal(true)
|
||||
onend (pendingInterim) → setTranscript(pendingInterim), setIsFinal(true)
|
||||
↑ fallback: Chrome often skips the final onresult when stop() is called manually
|
||||
```
|
||||
|
||||
### 11.3 Text Accumulation in ChatInput
|
||||
|
||||
```
|
||||
committedTextRef.current = '' // tracks finalized text (typed + confirmed speech)
|
||||
|
||||
isFinal=false (interim):
|
||||
setText(committedTextRef.current + ' ' + transcript)
|
||||
// textarea shows live speech; committedTextRef unchanged
|
||||
|
||||
isFinal=true (final):
|
||||
committedTextRef.current += ' ' + transcript
|
||||
setText(committedTextRef.current)
|
||||
// both textarea and ref updated — text is now "committed"
|
||||
|
||||
User types manually:
|
||||
setText(e.target.value)
|
||||
committedTextRef.current = e.target.value
|
||||
// keeps both in sync so next speech session appends correctly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. File Attachments
|
||||
|
||||
`ChatInput` uses Ant Design `Upload` with `beforeUpload` returning `false` (prevents auto-upload). Files accumulate in `pendingFiles: UploadFile[]` state. On send, files are converted to data URIs (`fileToDataUrl`) and stored on the `Message` as `attachments[]`.
|
||||
|
||||
**Accepted types:** `image/*`, `.pdf`, `.txt`, `.log`, `.csv`, `.json`
|
||||
|
||||
**Rendered in MessageBubble:**
|
||||
- Images → inline `<img>` preview
|
||||
- Other files → file badge chip (name + type)
|
||||
|
||||
---
|
||||
|
||||
## 13. Development Mode (Mock)
|
||||
|
||||
Set `VITE_AI_MOCK=true` in `.env.local` to use the mock API instead of the real SSE endpoint.
|
||||
|
||||
```typescript
|
||||
// store/useAIAssistantStore.ts
|
||||
const USE_MOCK_AI = import.meta.env.VITE_AI_MOCK === 'true';
|
||||
const chat = USE_MOCK_AI ? mockStreamChat : streamChat;
|
||||
```
|
||||
|
||||
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. It selects canned responses from keyword matching on the last user message and simulates word-by-word streaming with 15–45ms random delays.
|
||||
|
||||
**Trigger keywords:**
|
||||
|
||||
| Keyword(s) | Response type |
|
||||
|---|---|
|
||||
| `filter logs`, `payment` + `error` | `ai-action`: logs.runQuery |
|
||||
| `add filter` | `ai-action`: logs.addFilter |
|
||||
| `change view` / `timeseries view` | `ai-action`: logs.changeView |
|
||||
| `save view` | `ai-action`: logs.saveView |
|
||||
| `error` / `exception` | Error rates table + trace snippet |
|
||||
| `latency` / `p99` / `graph` | Line chart (p99 latency) |
|
||||
| `bar` / `top service` | Bar chart (error count) |
|
||||
| `pie` / `distribution` | Pie / doughnut chart |
|
||||
| `timeseries` / `table` | Timeseries data table |
|
||||
| `log` | Top log errors summary |
|
||||
| `confirm` / `alert` / `anomal` | `ai-confirm` block |
|
||||
| `environment` / `question` | `ai-question` (radio) |
|
||||
| `level` / `select` / `filter` | `ai-question` (checkbox) |
|
||||
|
||||
---
|
||||
|
||||
## 14. Adding a New Page's Actions
|
||||
|
||||
### Step 1 — Create an aiActions file
|
||||
|
||||
```typescript
|
||||
// src/pages/TracesExplorer/aiActions.ts
|
||||
import { PageAction } from 'container/AIAssistant/pageActions/types';
|
||||
|
||||
interface FilterTracesParams {
|
||||
service: string;
|
||||
minDurationMs?: number;
|
||||
}
|
||||
|
||||
export function tracesFilterAction(deps: {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: (q: Query) => void;
|
||||
}): PageAction<FilterTracesParams> {
|
||||
return {
|
||||
id: 'traces.filter', // globally unique: pageName.actionName
|
||||
description: 'Filter traces by service name and minimum duration',
|
||||
autoApply: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string', description: 'Service name to filter by' },
|
||||
minDurationMs: { type: 'number', description: 'Minimum span duration in ms' },
|
||||
},
|
||||
required: ['service'],
|
||||
},
|
||||
execute: async ({ service, minDurationMs }) => {
|
||||
// Build updated query and redirect
|
||||
deps.redirectWithQueryBuilderData(buildUpdatedQuery(service, minDurationMs));
|
||||
return { summary: `Filtered traces for ${service}` };
|
||||
},
|
||||
getContext: () => ({
|
||||
currentFilters: deps.currentQuery.builder.queryData[0].filters.items,
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register in the page component
|
||||
|
||||
```typescript
|
||||
// src/pages/TracesExplorer/index.tsx
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import { tracesFilterAction } from './aiActions';
|
||||
|
||||
function TracesExplorer() {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const aiActions = useMemo(() => [
|
||||
tracesFilterAction({ currentQuery, redirectWithQueryBuilderData }),
|
||||
], [currentQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `pageId` must be unique across pages (kebab-case convention)
|
||||
- `actionId` must be globally unique — use `pageName.actionName` convention
|
||||
- `actions` array **must be memoized** (`useMemo`) — identity change triggers re-registration
|
||||
- For URL-driven state (QueryBuilder), always use the URL-sync API; never use `handleSetQueryData` alone
|
||||
- `getContext()` should return only what the AI needs to make decisions — keep it minimal
|
||||
|
||||
---
|
||||
|
||||
## 15. Adding a New Block Type
|
||||
|
||||
### Step 1 — Create the component
|
||||
|
||||
```typescript
|
||||
// src/container/AIAssistant/components/blocks/MyBlock.tsx
|
||||
import { useContext } from 'react';
|
||||
import MessageContext from '../MessageContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
interface MyBlockPayload {
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export default function MyBlock({ content }: { content: string }): JSX.Element {
|
||||
const payload = JSON.parse(content) as MyBlockPayload;
|
||||
const { messageId } = useContext(MessageContext);
|
||||
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
|
||||
const answered = useAIAssistantStore(s => s.answeredBlocks[messageId]);
|
||||
|
||||
if (answered) return <div className="ai-block--answered">Done</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{payload.title}</h4>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register in index.ts
|
||||
|
||||
```typescript
|
||||
// src/container/AIAssistant/components/blocks/index.ts
|
||||
import MyBlock from './MyBlock';
|
||||
BlockRegistry.register('myblock', MyBlock);
|
||||
```
|
||||
|
||||
### Step 3 — AI emits the block tag
|
||||
|
||||
````markdown
|
||||
```ai-myblock
|
||||
{
|
||||
"title": "Something",
|
||||
"items": ["a", "b"]
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 16. Data Contracts
|
||||
|
||||
### 16.1 API Request
|
||||
|
||||
```typescript
|
||||
POST /api/v1/assistant/threads
|
||||
{
|
||||
conversationId: string,
|
||||
messages: Array<{
|
||||
role: 'user' | 'assistant',
|
||||
content: string // last user message includes [PAGE_CONTEXT]...[/PAGE_CONTEXT] prefix
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
### 16.2 SSE Event Schema
|
||||
|
||||
```typescript
|
||||
interface SSEEvent {
|
||||
type: 'message';
|
||||
messageId: string; // server-assigned; consistent across all chunks of one turn
|
||||
role: 'assistant';
|
||||
content: string; // incremental chunk — NOT cumulative
|
||||
done: boolean; // true on the last event of a turn
|
||||
actions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'follow_up' | 'open_resource' | 'navigate' | 'apply_filter' | 'open_docs' | 'undo' | 'revert';
|
||||
payload: Record<string, unknown>;
|
||||
expiresAt: string | null; // ISO-8601 or null
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### 16.3 ai-action Block Payload (embedded in content stream)
|
||||
|
||||
```typescript
|
||||
{
|
||||
actionId: string, // must match a registered PageAction.id
|
||||
description: string, // shown in the confirmation card (autoApply=false)
|
||||
parameters: Record<string, unknown> // must conform to the action's JSON Schema
|
||||
}
|
||||
```
|
||||
|
||||
### 16.4 PageAction Interface
|
||||
|
||||
```typescript
|
||||
interface PageAction<TParams = Record<string, any>> {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
execute: (params: TParams) => Promise<{ summary: string; data?: unknown }>;
|
||||
getContext?: () => unknown; // called on every sendMessage() to populate PAGE_CONTEXT
|
||||
autoApply?: boolean; // default false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Key Design Decisions
|
||||
|
||||
### Context injection is wire-only
|
||||
PAGE_CONTEXT is injected into the wire payload but never stored or shown in the UI. This keeps conversations readable, avoids polluting history with system context, and ensures the AI always gets fresh page state on every message rather than stale state from when the conversation started.
|
||||
|
||||
### URL-driven query builders require URL-sync APIs
|
||||
Pages that use URL-driven state (e.g., `QueryBuilderV2` with `compositeQuery` URL param) **must** use the URL-sync API (`redirectWithQueryBuilderData`) when actions mutate query state. Using React `setState` alone does not update the URL, so the displayed filters do not change. This was the root cause of the first major bug in the Logs Explorer integration.
|
||||
|
||||
### autoApply co-located with action definition
|
||||
The `autoApply` flag lives on the `PageAction` definition, not in the UI layer. The page that owns the action knows whether it is safe to apply without confirmation. Additive / reversible actions use `autoApply: true`. Actions that create persistent artifacts (saved views, alert rules) use `autoApply: false`.
|
||||
|
||||
### Transcript-as-state for voice input
|
||||
`useSpeechRecognition` exposes `transcript` and `isFinal` as React state rather than using an `onTranscript` callback. The callback approach had a race condition: recognition events could fire before the `useEffect` that wired up the callback had run, leaving `onTranscriptRef.current` unset. State-based approach uses normal React reactivity with no timing dependency.
|
||||
|
||||
### Block answer persistence across re-mounts
|
||||
Interactive blocks persist their answered state to `answeredBlocks[messageId]` in the Zustand store. Without this, switching UI modes or scrolling away and back would reset blocks to their unanswered state, allowing the user to re-submit answers and send duplicate messages to the AI.
|
||||
|
||||
### Panel resize is not persisted
|
||||
Panel width resets to 380px on close/reopen. If persistence is needed, save `panelWidth` to `localStorage` in the drag `onMouseUp` handler and initialize `useState` from it.
|
||||
|
||||
### Mock API shares the same interface
|
||||
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. The store switches between them via `VITE_AI_MOCK=true`. This means the mock exercises the exact same store code path as production — no separate code branch to maintain.
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* AIAssistantIcon — SigNoz AI Assistant icon (V2 — Minimal Line).
|
||||
*
|
||||
* Single-weight stroke outline of a bear face. Inherits `currentColor` so it
|
||||
* adapts to any dark/light context automatically. The only hard-coded color is
|
||||
* the SigNoz red (#E8432D) eye bar — one bold accent, nothing else.
|
||||
*/
|
||||
|
||||
interface AIAssistantIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AIAssistantIcon({
|
||||
size = 24,
|
||||
className,
|
||||
}: AIAssistantIconProps): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-label="AI Assistant"
|
||||
role="img"
|
||||
>
|
||||
{/* Ears */}
|
||||
<path
|
||||
d="M8 13.5 C8 8 5 6 5 11 C5 14 7 15.5 9.5 15.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 13.5 C24 8 27 6 27 11 C27 14 25 15.5 22.5 15.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Head */}
|
||||
<rect
|
||||
x="7"
|
||||
y="12"
|
||||
width="18"
|
||||
height="15"
|
||||
rx="7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Eye bar — SigNoz red, the only accent */}
|
||||
<line
|
||||
x1="10"
|
||||
y1="18"
|
||||
x2="22"
|
||||
y2="18"
|
||||
stroke="#E8432D"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="18" r="1" fill="#E8432D" />
|
||||
<circle cx="20" cy="18" r="1" fill="#E8432D" />
|
||||
|
||||
{/* Nose */}
|
||||
<ellipse
|
||||
cx="16"
|
||||
cy="23.5"
|
||||
rx="1.6"
|
||||
ry="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Check, Shield, X } from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { PendingApproval } from '../types';
|
||||
|
||||
interface ApprovalCardProps {
|
||||
conversationId: string;
|
||||
approval: PendingApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the agent emits an `approval` SSE event.
|
||||
* The agent has paused execution; the user must approve or reject
|
||||
* before the stream resumes on a new execution.
|
||||
*/
|
||||
export default function ApprovalCard({
|
||||
conversationId,
|
||||
approval,
|
||||
}: ApprovalCardProps): JSX.Element {
|
||||
const approveAction = useAIAssistantStore((s) => s.approveAction);
|
||||
const rejectAction = useAIAssistantStore((s) => s.rejectAction);
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
|
||||
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
|
||||
|
||||
const handleApprove = async (): Promise<void> => {
|
||||
setDecided('approved');
|
||||
await approveAction(conversationId, approval.approvalId);
|
||||
};
|
||||
|
||||
const handleReject = async (): Promise<void> => {
|
||||
setDecided('rejected');
|
||||
await rejectAction(conversationId, approval.approvalId);
|
||||
};
|
||||
|
||||
// After decision the card shows a compact confirmation row
|
||||
if (decided === 'approved') {
|
||||
return (
|
||||
<div className="ai-approval ai-approval--decided">
|
||||
<Check
|
||||
size={13}
|
||||
className="ai-approval__status-icon ai-approval__status-icon--ok"
|
||||
/>
|
||||
<span className="ai-approval__status-text">Approved — resuming…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (decided === 'rejected') {
|
||||
return (
|
||||
<div className="ai-approval ai-approval--decided">
|
||||
<X
|
||||
size={13}
|
||||
className="ai-approval__status-icon ai-approval__status-icon--no"
|
||||
/>
|
||||
<span className="ai-approval__status-text">Rejected.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-approval">
|
||||
<div className="ai-approval__header">
|
||||
<Shield size={13} className="ai-approval__shield-icon" />
|
||||
<span className="ai-approval__header-label">Action requires approval</span>
|
||||
<span className="ai-approval__resource-badge">
|
||||
{approval.actionType} · {approval.resourceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-approval__summary">{approval.summary}</p>
|
||||
|
||||
{approval.diff && (
|
||||
<div className="ai-approval__diff">
|
||||
{approval.diff.before !== undefined && (
|
||||
<div className="ai-approval__diff-block ai-approval__diff-block--before">
|
||||
<span className="ai-approval__diff-label">Before</span>
|
||||
<pre className="ai-approval__diff-json">
|
||||
{JSON.stringify(approval.diff.before, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{approval.diff.after !== undefined && (
|
||||
<div className="ai-approval__diff-block ai-approval__diff-block--after">
|
||||
<span className="ai-approval__diff-label">After</span>
|
||||
<pre className="ai-approval__diff-json">
|
||||
{JSON.stringify(approval.diff.after, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-approval__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isStreaming}
|
||||
prefix={<Check />}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleReject}
|
||||
disabled={isStreaming}
|
||||
prefix={<X />}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@signozhq/ui';
|
||||
import type { UploadFile } from 'antd';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
// eslint-disable-next-line
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../types';
|
||||
import { MessageContext } from '../../../api/ai/chat';
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
Mic,
|
||||
Plus,
|
||||
Rows3,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (
|
||||
text: string,
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
) => void;
|
||||
onCancel?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const MAX_INPUT_LENGTH = 20000;
|
||||
const WARNING_THRESHOLD = 15000;
|
||||
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
const CONTEXT_CATEGORIES = [
|
||||
'Dashboards',
|
||||
'Alerts',
|
||||
'Services',
|
||||
'Saved Views',
|
||||
] as const;
|
||||
|
||||
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
|
||||
|
||||
interface SelectedContextItem {
|
||||
category: ContextCategory;
|
||||
entityId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function toMessageContext(item: SelectedContextItem): MessageContext | null {
|
||||
switch (item.category) {
|
||||
case 'Dashboards':
|
||||
return {
|
||||
source: 'mention',
|
||||
type: 'dashboard',
|
||||
resourceId: item.entityId,
|
||||
resourceName: item.value,
|
||||
};
|
||||
case 'Alerts':
|
||||
return {
|
||||
source: 'mention',
|
||||
type: 'alert',
|
||||
resourceId: item.entityId,
|
||||
resourceName: item.value,
|
||||
};
|
||||
case 'Services':
|
||||
return {
|
||||
source: 'mention',
|
||||
type: 'service',
|
||||
resourceId: item.entityId,
|
||||
resourceName: item.value,
|
||||
};
|
||||
case 'Saved Views':
|
||||
return {
|
||||
source: 'mention',
|
||||
type: 'saved_view',
|
||||
resourceId: item.entityId,
|
||||
resourceName: item.value,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ContextEntityItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CONTEXT_CATEGORY_ICONS: Record<
|
||||
ContextCategory,
|
||||
(props: { size?: number }) => JSX.Element
|
||||
> = {
|
||||
Dashboards: LayoutDashboard,
|
||||
Alerts: Bell,
|
||||
Services: ShieldCheck,
|
||||
'Saved Views': Rows3,
|
||||
};
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
onCancel,
|
||||
disabled,
|
||||
isStreaming = false,
|
||||
}: ChatInputProps): JSX.Element {
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const [text, setText] = useState('');
|
||||
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
|
||||
const [selectedContexts, setSelectedContexts] = useState<
|
||||
SelectedContextItem[]
|
||||
>([]);
|
||||
const [isContextPickerOpen, setIsContextPickerOpen] = useState(false);
|
||||
const [activeContextCategory, setActiveContextCategory] =
|
||||
useState<ContextCategory>('Dashboards');
|
||||
const [servicesTimeRange] = useState(() => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
startTime: now - HOME_SERVICES_INTERVAL,
|
||||
endTime: now,
|
||||
};
|
||||
});
|
||||
// Stores the already-committed final text so interim results don't overwrite it
|
||||
const committedTextRef = useRef('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const capText = useCallback(
|
||||
(value: string) => value.slice(0, MAX_INPUT_LENGTH),
|
||||
[],
|
||||
);
|
||||
|
||||
const syncContextPickerFromText = useCallback(
|
||||
(value: string, caret: number) => {
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const atIndex = beforeCaret.lastIndexOf('@');
|
||||
if (atIndex < 0) {
|
||||
setIsContextPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
const query = beforeCaret.slice(atIndex + 1);
|
||||
if (/\s/.test(query)) {
|
||||
setIsContextPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsContextPickerOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleContextSelection = useCallback(
|
||||
(category: ContextCategory, entityId: string, contextValue: string) => {
|
||||
setSelectedContexts((prev) => {
|
||||
const alreadySelected = prev.some(
|
||||
(item) => item.category === category && item.entityId === entityId,
|
||||
);
|
||||
|
||||
if (alreadySelected) {
|
||||
return prev.filter(
|
||||
(item) => !(item.category === category && item.entityId === entityId),
|
||||
);
|
||||
}
|
||||
|
||||
return [...prev, { category, entityId, value: contextValue }];
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Focus the textarea when this component mounts (panel/modal open)
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed && pendingFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments: MessageAttachment[] = await Promise.all(
|
||||
pendingFiles.map(async (f) => {
|
||||
const dataUrl = f.originFileObj ? await fileToDataUrl(f.originFileObj) : '';
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type ?? 'application/octet-stream',
|
||||
dataUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const contexts = selectedContexts
|
||||
.map(toMessageContext)
|
||||
.filter((context): context is MessageContext => context !== null);
|
||||
const payload = capText(trimmed);
|
||||
|
||||
onSend(
|
||||
payload,
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
contexts.length > 0 ? contexts : undefined,
|
||||
);
|
||||
setText('');
|
||||
committedTextRef.current = '';
|
||||
setPendingFiles([]);
|
||||
setSelectedContexts([]);
|
||||
textareaRef.current?.focus();
|
||||
}, [text, pendingFiles, onSend, selectedContexts, capText]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape' && isContextPickerOpen) {
|
||||
setIsContextPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend, isContextPickerOpen],
|
||||
);
|
||||
|
||||
const removeFile = useCallback((uid: string) => {
|
||||
setPendingFiles((prev) => prev.filter((f) => f.uid !== uid));
|
||||
}, []);
|
||||
|
||||
const removeContext = useCallback(
|
||||
(category: ContextCategory, entityId: string) => {
|
||||
setSelectedContexts((prev) =>
|
||||
prev.filter(
|
||||
(item) => !(item.category === category && item.entityId === entityId),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Voice input ────────────────────────────────────────────────────────────
|
||||
|
||||
const { isListening, isSupported, start, discard } = useSpeechRecognition({
|
||||
onTranscript: (transcriptText, isFinal) => {
|
||||
if (isFinal) {
|
||||
// Commit: append to whatever the user has already typed
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
const next = capText(committedTextRef.current + separator + transcriptText);
|
||||
committedTextRef.current = next;
|
||||
setText(next);
|
||||
} else {
|
||||
// Interim: live preview appended to committed text, not yet persisted
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
setText(capText(committedTextRef.current + separator + transcriptText));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Stop recording and immediately send whatever is in the textarea.
|
||||
const handleStopAndSend = useCallback(async () => {
|
||||
// Promote the displayed text (interim included) to committed so handleSend sees it.
|
||||
committedTextRef.current = capText(text);
|
||||
// Stop recognition without triggering onTranscript again (would double-append).
|
||||
discard();
|
||||
await handleSend();
|
||||
}, [text, discard, handleSend, capText]);
|
||||
|
||||
// Stop recording and revert the textarea to what it was before voice started.
|
||||
const handleDiscard = useCallback(() => {
|
||||
discard();
|
||||
setText(committedTextRef.current);
|
||||
textareaRef.current?.focus();
|
||||
}, [discard]);
|
||||
|
||||
const {
|
||||
data: dashboardsResponse,
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
} = useGetAllDashboard();
|
||||
|
||||
const {
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
query: {
|
||||
enabled: activeContextCategory === 'Alerts',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: servicesResponse,
|
||||
isLoading: isServicesLoading,
|
||||
isFetching: isServicesFetching,
|
||||
isError: isServicesError,
|
||||
} = useQueryService({
|
||||
minTime: servicesTimeRange.startTime * 1e6,
|
||||
maxTime: servicesTimeRange.endTime * 1e6,
|
||||
selectedTime,
|
||||
selectedTags: [],
|
||||
options: {
|
||||
enabled: activeContextCategory === 'Services',
|
||||
},
|
||||
});
|
||||
|
||||
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
|
||||
{
|
||||
Dashboards:
|
||||
dashboardsResponse?.data?.map((dashboard) => ({
|
||||
id: dashboard.id,
|
||||
value: dashboard.data.title ?? 'Untitled',
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
})) ?? [],
|
||||
Services:
|
||||
servicesResponse
|
||||
?.filter((serviceItem) => Boolean(serviceItem.serviceName))
|
||||
.map((serviceItem, index) => ({
|
||||
id: serviceItem.serviceName || `service-${index}`,
|
||||
value: serviceItem.serviceName,
|
||||
})) ?? [],
|
||||
'Saved Views': [],
|
||||
};
|
||||
|
||||
const contextCategoryStateByCategory: Record<
|
||||
ContextCategory,
|
||||
{ isLoading: boolean; isError: boolean }
|
||||
> = {
|
||||
Dashboards: {
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
},
|
||||
Alerts: {
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
},
|
||||
Services: {
|
||||
isLoading: isServicesLoading || isServicesFetching,
|
||||
isError: isServicesError,
|
||||
},
|
||||
'Saved Views': {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
},
|
||||
};
|
||||
|
||||
const filteredContextOptions =
|
||||
contextEntitiesByCategory[activeContextCategory];
|
||||
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
|
||||
contextCategoryStateByCategory[activeContextCategory];
|
||||
const currentLength = text.length;
|
||||
const showTextWarning = currentLength >= WARNING_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-input" ref={inputRootRef}>
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="ai-assistant-input__attachments">
|
||||
{pendingFiles.map((f) => (
|
||||
<div key={f.uid} className="ai-assistant-input__attachment-chip">
|
||||
<span className="ai-assistant-input__attachment-name">{f.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ai-assistant-input__attachment-remove"
|
||||
onClick={(): void => removeFile(f.uid)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedContexts.length > 0 && (
|
||||
<div className="ai-assistant-input__context-tags">
|
||||
{selectedContexts.map((contextItem) => (
|
||||
<div
|
||||
key={`${contextItem.category}:${contextItem.entityId}`}
|
||||
className="ai-assistant-input__context-tag"
|
||||
>
|
||||
<div className="ai-assistant-input__context-tag-content">
|
||||
<Badge
|
||||
color="primary"
|
||||
variant="outline"
|
||||
className="ai-assistant-input__context-tag-category"
|
||||
>
|
||||
{contextItem.category}
|
||||
</Badge>
|
||||
<span className="ai-assistant-input__context-tag-label">
|
||||
{contextItem.value}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="ai-assistant-input__context-tag-remove"
|
||||
onClick={(): void =>
|
||||
removeContext(contextItem.category, contextItem.entityId)
|
||||
}
|
||||
aria-label={`Remove ${contextItem.category}: ${contextItem.value} context`}
|
||||
prefix={<X size={10} />}
|
||||
></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-assistant-input__composer">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-assistant-input__textarea"
|
||||
placeholder="Ask anything… (Shift+Enter for new line)"
|
||||
value={text}
|
||||
onChange={(e): void => {
|
||||
const next = capText(e.target.value);
|
||||
setText(next);
|
||||
// Keep committed text in sync when the user edits manually
|
||||
committedTextRef.current = next;
|
||||
syncContextPickerFromText(next, e.target.selectionStart ?? next.length);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
maxLength={MAX_INPUT_LENGTH}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{showTextWarning && (
|
||||
<div className="ai-assistant-input__char-warning" role="status">
|
||||
<TriangleAlert size={12} />
|
||||
<span>
|
||||
{currentLength}/{MAX_INPUT_LENGTH} characters. Limit is {MAX_INPUT_LENGTH}
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-assistant-input__footer">
|
||||
<div className="ai-assistant-input__left-actions">
|
||||
{/* <Upload
|
||||
multiple
|
||||
accept="image/*,.pdf,.txt,.log,.csv,.json"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file): boolean => {
|
||||
setPendingFiles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
originFileObj: file,
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
className="ai-assistant-input__attach-btn"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</Button>
|
||||
</Upload> */}
|
||||
|
||||
<Popover
|
||||
open={isContextPickerOpen}
|
||||
onOpenChange={(open): void => {
|
||||
setIsContextPickerOpen(open);
|
||||
if (!open) {
|
||||
setActiveContextCategory('Dashboards');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={(): void => {
|
||||
setActiveContextCategory('Dashboards');
|
||||
}}
|
||||
prefix={<Plus size={10} />}
|
||||
>
|
||||
Add Context
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="ai-context-popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="ai-context-popover__content">
|
||||
<div className="ai-context-popover__categories">
|
||||
{CONTEXT_CATEGORIES.map((category) => {
|
||||
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
className={`ai-context-popover__category-item ${
|
||||
activeContextCategory === category
|
||||
? 'ai-context-popover__category-item--active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => setActiveContextCategory(category)}
|
||||
>
|
||||
<CategoryIcon size={13} />
|
||||
<span>{category}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ai-context-popover__entities">
|
||||
{isActiveContextLoading ? (
|
||||
<div className="ai-context-popover__empty">
|
||||
Loading {activeContextCategory.toLowerCase()}...
|
||||
</div>
|
||||
) : isActiveContextError ? (
|
||||
<div className="ai-context-popover__empty">
|
||||
Failed to load {activeContextCategory.toLowerCase()}.
|
||||
</div>
|
||||
) : filteredContextOptions.length === 0 ? (
|
||||
<div className="ai-context-popover__empty">No matching entities</div>
|
||||
) : (
|
||||
filteredContextOptions.map((option) => {
|
||||
const isSelected = selectedContexts.some(
|
||||
(item) =>
|
||||
item.category === activeContextCategory &&
|
||||
item.entityId === option.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={`ai-context-popover__entity-item ${
|
||||
isSelected ? 'ai-context-popover__entity-item--selected' : ''
|
||||
}`}
|
||||
onClick={(): void =>
|
||||
toggleContextSelection(
|
||||
activeContextCategory,
|
||||
option.id,
|
||||
option.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="ai-context-popover__entity-item-text">
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-input__right-actions">
|
||||
{isListening ? (
|
||||
<div className="ai-mic-recording">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-mic-recording__discard"
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<span className="ai-mic-recording__waves" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-mic-recording__stop"
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
>
|
||||
<Square size={9} fill="currentColor" strokeWidth={0} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
!isSupported
|
||||
? 'Voice input not supported in this browser'
|
||||
: 'Voice input'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={start}
|
||||
disabled={disabled || !isSupported}
|
||||
aria-label="Start voice input"
|
||||
className="ai-mic-btn"
|
||||
>
|
||||
<Mic size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isStreaming && onCancel ? (
|
||||
<Tooltip title="Stop generating">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
color="destructive"
|
||||
onClick={onCancel}
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={10} fill="currentColor" strokeWidth={0} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
color="primary"
|
||||
onClick={isListening ? handleStopAndSend : handleSend}
|
||||
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { CircleHelp, Send, X } from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { ClarificationField, PendingClarification } from '../types';
|
||||
|
||||
interface ClarificationFormProps {
|
||||
conversationId: string;
|
||||
clarification: PendingClarification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the agent emits a `clarification` SSE event.
|
||||
* Dynamically renders form fields based on the `fields` array and
|
||||
* submits answers to resume the agent on a new execution.
|
||||
*/
|
||||
export default function ClarificationForm({
|
||||
conversationId,
|
||||
clarification,
|
||||
}: ClarificationFormProps): JSX.Element {
|
||||
const submitClarification = useAIAssistantStore((s) => s.submitClarification);
|
||||
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
|
||||
const initialAnswers = Object.fromEntries(
|
||||
clarification.fields.map((f) => [f.id, f.default ?? '']),
|
||||
);
|
||||
const [answers, setAnswers] =
|
||||
useState<Record<string, unknown>>(initialAnswers);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
const setField = (id: string, value: unknown): void => {
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setSubmitted(true);
|
||||
await submitClarification(
|
||||
conversationId,
|
||||
clarification.clarificationId,
|
||||
answers,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setCancelled(true);
|
||||
cancelStream(conversationId);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="ai-clarification ai-clarification--submitted">
|
||||
<Send size={13} className="ai-clarification__icon" />
|
||||
<span className="ai-clarification__status-text">
|
||||
Answers submitted — resuming…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<div className="ai-clarification ai-clarification--submitted">
|
||||
<X size={13} className="ai-clarification__icon" />
|
||||
<span className="ai-clarification__status-text">Request cancelled.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-clarification">
|
||||
<div className="ai-clarification__header">
|
||||
<CircleHelp size={13} className="ai-clarification__header-icon" />
|
||||
<span className="ai-clarification__header-label">A few details needed</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-clarification__message">{clarification.message}</p>
|
||||
|
||||
<div className="ai-clarification__fields">
|
||||
{clarification.fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={answers[field.id]}
|
||||
onChange={(val): void => setField(field.id, val)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ai-clarification__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isStreaming}
|
||||
prefix={<Send />}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isStreaming}
|
||||
prefix={<X />}
|
||||
>
|
||||
Cancel request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field renderer — handles text, number, select, radio, checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldInputProps {
|
||||
field: ClarificationField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
const { id, type, label, required, options } = field;
|
||||
|
||||
if (type === 'select' && options) {
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<label className="ai-clarification__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
className="ai-clarification__select"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'radio' && options) {
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<span className="ai-clarification__label">
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</span>
|
||||
<div className="ai-clarification__radio-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="ai-clarification__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={id}
|
||||
value={opt}
|
||||
checked={value === opt}
|
||||
onChange={(): void => onChange(opt)}
|
||||
className="ai-clarification__radio"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && options) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
const toggle = (opt: string): void => {
|
||||
onChange(
|
||||
selected.includes(opt)
|
||||
? selected.filter((v) => v !== opt)
|
||||
: [...selected, opt],
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<span className="ai-clarification__label">
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</span>
|
||||
<div className="ai-clarification__checkbox-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="ai-clarification__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={(): void => toggle(opt)}
|
||||
className="ai-clarification__checkbox"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text / number (default)
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<label className="ai-clarification__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type === 'number' ? 'number' : 'text'}
|
||||
className="ai-clarification__input"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void =>
|
||||
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
placeholder={label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { Conversation } from '../types';
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRestore: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) {
|
||||
return 'just now';
|
||||
}
|
||||
if (mins < 60) {
|
||||
return `${mins}m ago`;
|
||||
}
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) {
|
||||
return `${hrs}h ago`;
|
||||
}
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ConversationItem({
|
||||
conversation,
|
||||
isActive,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
onRestore,
|
||||
}: ConversationItemProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isArchived = Boolean(conversation.archived);
|
||||
const displayTitle = conversation.title ?? 'New conversation';
|
||||
const ts = conversation.updatedAt ?? conversation.createdAt;
|
||||
|
||||
const startEditing = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(conversation.title ?? '');
|
||||
setIsEditing(true);
|
||||
},
|
||||
[conversation.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
onRename(conversation.id, editValue);
|
||||
setIsEditing(false);
|
||||
}, [conversation.id, editValue, onRename]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
commitEdit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[commitEdit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(conversation.id);
|
||||
},
|
||||
[conversation.id, onDelete],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRestore(conversation.id);
|
||||
},
|
||||
[conversation.id, onRestore],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-history__item${isActive ? ' ai-history__item--active' : ''}${
|
||||
isArchived ? ' ai-history__item--archived' : ''
|
||||
}`}
|
||||
onClick={(): void => onSelect(conversation.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={12} className="ai-history__item-icon" />
|
||||
|
||||
<div className="ai-history__item-body">
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ai-history__item-input"
|
||||
value={editValue}
|
||||
onChange={(e): void => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={commitEdit}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
maxLength={80}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="ai-history__item-title" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
<span className="ai-history__item-time">{formatRelativeTime(ts)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="ai-history__item-actions">
|
||||
<Tooltip title="Rename">
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={startEditing}
|
||||
aria-label="Rename conversation"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{isArchived ? (
|
||||
<Tooltip title="Restore to conversations">
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleRestore}
|
||||
aria-label="Restore conversation"
|
||||
>
|
||||
<ArchiveRestore size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Archive">
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleDelete}
|
||||
aria-label="Archive conversation"
|
||||
>
|
||||
<Archive size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { Conversation } from '../types';
|
||||
import ConversationItem from './ConversationItem';
|
||||
|
||||
interface HistorySidebarProps {
|
||||
/** Called when a conversation is selected — lets the parent navigate if needed */
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
function groupByDate(
|
||||
conversations: Conversation[],
|
||||
): { label: string; items: Conversation[] }[] {
|
||||
const now = Date.now();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const groups: Record<string, Conversation[]> = {
|
||||
Today: [],
|
||||
Yesterday: [],
|
||||
'Last 7 days': [],
|
||||
'Last 30 days': [],
|
||||
Older: [],
|
||||
};
|
||||
|
||||
for (const conv of conversations) {
|
||||
const age = now - (conv.updatedAt ?? conv.createdAt);
|
||||
if (age < DAY) {
|
||||
groups.Today.push(conv);
|
||||
} else if (age < 2 * DAY) {
|
||||
groups.Yesterday.push(conv);
|
||||
} else if (age < 7 * DAY) {
|
||||
groups['Last 7 days'].push(conv);
|
||||
} else if (age < 30 * DAY) {
|
||||
groups['Last 30 days'].push(conv);
|
||||
} else {
|
||||
groups.Older.push(conv);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(groups)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
function HistoryListSkeleton({
|
||||
rows,
|
||||
inline,
|
||||
}: {
|
||||
rows: number;
|
||||
inline?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
inline
|
||||
? 'ai-history__skeleton ai-history__skeleton--inline'
|
||||
: 'ai-history__skeleton'
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<div key={i} className="ai-history__skeleton-row">
|
||||
<div className="ai-history__skeleton-icon" />
|
||||
<div className="ai-history__skeleton-text">
|
||||
<div className="ai-history__skeleton-line ai-history__skeleton-line--title" />
|
||||
<div className="ai-history__skeleton-line ai-history__skeleton-line--meta" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistorySidebar({
|
||||
onSelect,
|
||||
}: HistorySidebarProps): JSX.Element {
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const isLoadingThreads = useAIAssistantStore((s) => s.isLoadingThreads);
|
||||
const setActiveConversation = useAIAssistantStore(
|
||||
(s) => s.setActiveConversation,
|
||||
);
|
||||
const loadThread = useAIAssistantStore((s) => s.loadThread);
|
||||
const fetchThreads = useAIAssistantStore((s) => s.fetchThreads);
|
||||
const deleteConversation = useAIAssistantStore((s) => s.deleteConversation);
|
||||
const restoreConversation = useAIAssistantStore((s) => s.restoreConversation);
|
||||
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
|
||||
|
||||
// Fetch threads from backend on mount
|
||||
useEffect(() => {
|
||||
void fetchThreads();
|
||||
}, [fetchThreads]);
|
||||
|
||||
const sortedActive = useMemo(
|
||||
() =>
|
||||
Object.values(conversations)
|
||||
.filter((c) => !c.archived)
|
||||
.sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
),
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const sortedArchived = useMemo(
|
||||
() =>
|
||||
Object.values(conversations)
|
||||
.filter((c) => Boolean(c.archived) && c.threadId)
|
||||
.sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
),
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => groupByDate(sortedActive), [sortedActive]);
|
||||
|
||||
const hasAnySidebarRows = groups.length > 0 || sortedArchived.length > 0;
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
const conv = conversations[id];
|
||||
if (conv?.threadId) {
|
||||
// Always load from backend — refreshes messages and reconnects
|
||||
// to active execution if the thread is still busy.
|
||||
void loadThread(conv.threadId);
|
||||
} else {
|
||||
// Local-only conversation (no backend thread yet)
|
||||
setActiveConversation(id);
|
||||
}
|
||||
onSelect?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-history">
|
||||
<div className="ai-history__header">
|
||||
<span className="ai-history__heading">Conversations</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-history__list" aria-busy={isLoadingThreads}>
|
||||
{isLoadingThreads && (
|
||||
<span className="ai-history__sr-only" role="status">
|
||||
Loading conversations
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isLoadingThreads && hasAnySidebarRows && (
|
||||
<HistoryListSkeleton rows={2} inline />
|
||||
)}
|
||||
|
||||
{isLoadingThreads && !hasAnySidebarRows && <HistoryListSkeleton rows={7} />}
|
||||
|
||||
{!isLoadingThreads && !hasAnySidebarRows && (
|
||||
<p className="ai-history__empty">No conversations yet.</p>
|
||||
)}
|
||||
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className="ai-history__group">
|
||||
<span className="ai-history__group-label">{label}</span>
|
||||
{items.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
onRename={renameConversation}
|
||||
onDelete={deleteConversation}
|
||||
onRestore={restoreConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedArchived.length > 0 && (
|
||||
<div className="ai-history__group ai-history__group--archived">
|
||||
<span className="ai-history__group-label">Archived Conversations</span>
|
||||
{sortedArchived.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
onRename={renameConversation}
|
||||
onDelete={deleteConversation}
|
||||
onRestore={restoreConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import './blocks';
|
||||
|
||||
import { Message, MessageBlock } from '../types';
|
||||
import { RichCodeBlock } from './blocks';
|
||||
import { MessageContext } from './MessageContext';
|
||||
import MessageFeedback from './MessageFeedback';
|
||||
import ThinkingStep from './ThinkingStep';
|
||||
import ToolCallStep from './ToolCallStep';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* react-markdown renders fenced code blocks as <pre><code>...</code></pre>.
|
||||
* When RichCodeBlock replaces <code> with a custom AI block component, the
|
||||
* block ends up wrapped in <pre> which forces monospace font and white-space:pre.
|
||||
* This renderer detects that case and unwraps the <pre>.
|
||||
*/
|
||||
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const childArr = React.Children.toArray(children);
|
||||
if (childArr.length === 1) {
|
||||
const child = childArr[0];
|
||||
// If the code component returned something other than a <code> element
|
||||
// (i.e. a custom AI block), render without the <pre> wrapper.
|
||||
if (React.isValidElement(child) && child.type !== 'code') {
|
||||
return <>{child}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Renders a single MessageBlock by type. */
|
||||
function renderBlock(block: MessageBlock, index: number): JSX.Element {
|
||||
switch (block.type) {
|
||||
case 'thinking':
|
||||
return <ThinkingStep key={index} content={block.content} />;
|
||||
case 'tool_call':
|
||||
// Blocks in a persisted message are always complete — done is always true.
|
||||
return (
|
||||
<ToolCallStep
|
||||
key={index}
|
||||
toolCall={{
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={index}
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const isUser = message.role === 'user';
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-message ai-message--${isUser ? 'user' : 'assistant'}`}
|
||||
data-testid={`ai-message-${message.id}`}
|
||||
>
|
||||
<div className="ai-message__body">
|
||||
<div className="ai-message__bubble">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="ai-message__attachments">
|
||||
{message.attachments.map((att) => {
|
||||
const isImage = att.type.startsWith('image/');
|
||||
return isImage ? (
|
||||
<img
|
||||
key={att.name}
|
||||
src={att.dataUrl}
|
||||
alt={att.name}
|
||||
className="ai-message__attachment-image"
|
||||
/>
|
||||
) : (
|
||||
<div key={att.name} className="ai-message__attachment-file">
|
||||
{att.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className="ai-message__text">{message.content}</p>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{/* eslint-disable-next-line react/no-array-index-key */}
|
||||
{message.blocks!.map((block, i) => renderBlock(block, i))}
|
||||
</MessageContext.Provider>
|
||||
) : (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
<ReactMarkdown
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</MessageContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface MessageContextValue {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const MessageContext = createContext<MessageContextValue>({
|
||||
messageId: '',
|
||||
});
|
||||
|
||||
export const useMessageContext = (): MessageContextValue =>
|
||||
useContext(MessageContext);
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { FeedbackRating, Message } from '../types';
|
||||
|
||||
interface MessageFeedbackProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const diffMs = Date.now() - timestamp;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 10) {
|
||||
return 'just now';
|
||||
}
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}s ago`;
|
||||
}
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) {
|
||||
return `${diffMin} min${diffMin === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) {
|
||||
return `${diffHr} hr${diffHr === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export default function MessageFeedback({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageFeedbackProps): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const submitMessageFeedback = useAIAssistantStore(
|
||||
(s) => s.submitMessageFeedback,
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// Local vote state — initialised from persisted feedbackRating, updated
|
||||
// immediately on click so the UI responds without waiting for the API.
|
||||
const [vote, setVote] = useState<FeedbackRating | null>(
|
||||
message.feedbackRating ?? null,
|
||||
);
|
||||
|
||||
const [relativeTime, setRelativeTime] = useState(() =>
|
||||
formatRelativeTime(message.createdAt),
|
||||
);
|
||||
|
||||
const absoluteTime = useMemo(
|
||||
() =>
|
||||
formatTimezoneAdjustedTimestamp(
|
||||
message.createdAt,
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
),
|
||||
[message.createdAt, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
// Tick relative time every 30 s
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setRelativeTime(formatRelativeTime(message.createdAt));
|
||||
}, 30_000);
|
||||
return (): void => clearInterval(id);
|
||||
}, [message.createdAt]);
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [copyToClipboard, message.content]);
|
||||
|
||||
const handleVote = useCallback(
|
||||
(rating: FeedbackRating): void => {
|
||||
if (vote === rating) {
|
||||
return;
|
||||
}
|
||||
setVote(rating);
|
||||
submitMessageFeedback(message.id, rating);
|
||||
},
|
||||
[vote, message.id, submitMessageFeedback],
|
||||
);
|
||||
|
||||
const feedbackClass = `ai-message-feedback${
|
||||
isLastAssistant ? ' ai-message-feedback--visible' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={feedbackClass}>
|
||||
<div className="ai-message-feedback__actions">
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
copied ? ' ai-message-feedback__btn--active' : ''
|
||||
}`}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Good response">
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
vote === 'positive' ? ' ai-message-feedback__btn--voted-up' : ''
|
||||
}`}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('positive')}
|
||||
>
|
||||
<ThumbsUp size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Bad response">
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
vote === 'negative' ? ' ai-message-feedback__btn--voted-down' : ''
|
||||
}`}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('negative')}
|
||||
>
|
||||
<ThumbsDown size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{onRegenerate && (
|
||||
<Tooltip title="Regenerate">
|
||||
<Button
|
||||
className="ai-message-feedback__btn"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="ai-message-feedback__time">
|
||||
{relativeTime} · {absoluteTime}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
PendingApproval,
|
||||
PendingClarification,
|
||||
StreamingEventItem,
|
||||
} from '../types';
|
||||
import ApprovalCard from './ApprovalCard';
|
||||
import { RichCodeBlock } from './blocks';
|
||||
import ClarificationForm from './ClarificationForm';
|
||||
import ThinkingStep from './ThinkingStep';
|
||||
import ToolCallStep from './ToolCallStep';
|
||||
|
||||
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const childArr = React.Children.toArray(children);
|
||||
if (childArr.length === 1) {
|
||||
const child = childArr[0];
|
||||
if (React.isValidElement(child) && child.type !== 'code') {
|
||||
return <>{child}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Human-readable labels for execution status codes shown before any events arrive. */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
queued: 'Queued…',
|
||||
running: 'Thinking…',
|
||||
awaiting_approval: 'Waiting for your approval…',
|
||||
awaiting_clarification: 'Waiting for your input…',
|
||||
resumed: 'Resumed…',
|
||||
};
|
||||
|
||||
interface StreamingMessageProps {
|
||||
conversationId: string;
|
||||
/** Ordered timeline of text and tool-call events in arrival order. */
|
||||
events: StreamingEventItem[];
|
||||
status?: string;
|
||||
pendingApproval?: PendingApproval | null;
|
||||
pendingClarification?: PendingClarification | null;
|
||||
}
|
||||
|
||||
export default function StreamingMessage({
|
||||
conversationId,
|
||||
events,
|
||||
status = '',
|
||||
pendingApproval = null,
|
||||
pendingClarification = null,
|
||||
}: StreamingMessageProps): JSX.Element {
|
||||
const statusLabel = STATUS_LABEL[status] ?? '';
|
||||
const isEmpty =
|
||||
events.length === 0 && !pendingApproval && !pendingClarification;
|
||||
|
||||
return (
|
||||
<div className="ai-message ai-message--assistant ai-message--streaming">
|
||||
<div className="ai-message__bubble">
|
||||
{/* Status pill or typing indicator — only before any events arrive */}
|
||||
{isEmpty && statusLabel && (
|
||||
<span className="ai-streaming-status">{statusLabel}</span>
|
||||
)}
|
||||
{isEmpty && !statusLabel && (
|
||||
<span className="ai-message__typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* eslint-disable react/no-array-index-key */}
|
||||
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
|
||||
{events.map((event, i) => {
|
||||
if (event.kind === 'tool') {
|
||||
return <ToolCallStep key={i} toolCall={event.toolCall} />;
|
||||
}
|
||||
if (event.kind === 'thinking') {
|
||||
return <ThinkingStep key={i} content={event.content} />;
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={i}
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
})}
|
||||
{/* eslint-enable react/no-array-index-key */}
|
||||
|
||||
{/* Approval / clarification cards appended after any streamed text */}
|
||||
{pendingApproval && (
|
||||
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
|
||||
)}
|
||||
{pendingClarification && (
|
||||
<ClarificationForm
|
||||
conversationId={conversationId}
|
||||
clarification={pendingClarification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
interface ThinkingStepProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Displays a collapsible thinking/reasoning block. */
|
||||
export default function ThinkingStep({
|
||||
content,
|
||||
}: ThinkingStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="ai-thinking-step">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-thinking-step__header"
|
||||
onClick={(): void => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Brain size={12} className="ai-thinking-step__icon" />
|
||||
<span className="ai-thinking-step__label">Thinking</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={11} className="ai-thinking-step__chevron" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="ai-thinking-step__chevron" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="ai-thinking-step__body">
|
||||
<p className="ai-thinking-step__content">{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LoaderCircle,
|
||||
Wrench,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { StreamingToolCall } from '../types';
|
||||
|
||||
interface ToolCallStepProps {
|
||||
toolCall: StreamingToolCall;
|
||||
}
|
||||
|
||||
/** Displays a single tool invocation, collapsible, with in/out detail. */
|
||||
export default function ToolCallStep({
|
||||
toolCall,
|
||||
}: ToolCallStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { toolName, input, result, done } = toolCall;
|
||||
|
||||
// Format tool name: "signoz_get_dashboard" → "Get Dashboard"
|
||||
const label = toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-tool-step ${
|
||||
done ? 'ai-tool-step--done' : 'ai-tool-step--running'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-tool-step__header"
|
||||
onClick={(): void => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{done ? (
|
||||
<Wrench
|
||||
size={12}
|
||||
className="ai-tool-step__icon ai-tool-step__icon--done"
|
||||
/>
|
||||
) : (
|
||||
<LoaderCircle
|
||||
size={12}
|
||||
className="ai-tool-step__icon ai-tool-step__icon--spin"
|
||||
/>
|
||||
)}
|
||||
<span className="ai-tool-step__label">{label}</span>
|
||||
<span className="ai-tool-step__tool-name">{toolName}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={11} className="ai-tool-step__chevron" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="ai-tool-step__chevron" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="ai-tool-step__body">
|
||||
<div className="ai-tool-step__section">
|
||||
<span className="ai-tool-step__section-label">Input</span>
|
||||
<pre className="ai-tool-step__json">{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className="ai-tool-step__section">
|
||||
<span className="ai-tool-step__section-label">Output</span>
|
||||
<pre className="ai-tool-step__json">
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import {
|
||||
Activity,
|
||||
TriangleAlert,
|
||||
ChartBar,
|
||||
Search,
|
||||
Zap,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { Message, StreamingEventItem } from '../types';
|
||||
import AIAssistantIcon from './AIAssistantIcon';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import StreamingMessage from './StreamingMessage';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
icon: TriangleAlert,
|
||||
text: 'Show me the top errors in the last hour',
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
text: 'What services have the highest latency?',
|
||||
},
|
||||
{
|
||||
icon: ChartBar,
|
||||
text: 'Give me an overview of system health',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
text: 'Find slow database queries',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
text: 'Which endpoints have the most 5xx errors?',
|
||||
},
|
||||
];
|
||||
|
||||
const EMPTY_EVENTS: StreamingEventItem[] = [];
|
||||
|
||||
interface VirtualizedMessagesProps {
|
||||
conversationId: string;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export default function VirtualizedMessages({
|
||||
conversationId,
|
||||
messages,
|
||||
isStreaming,
|
||||
}: VirtualizedMessagesProps): JSX.Element {
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
);
|
||||
const streamingEvents = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingEvents ?? EMPTY_EVENTS,
|
||||
);
|
||||
const pendingApproval = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingApproval ?? null,
|
||||
);
|
||||
const pendingClarification = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingClarification ?? null,
|
||||
);
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
|
||||
const handleRegenerate = useCallback((): void => {
|
||||
if (lastUserMessage && !isStreaming) {
|
||||
sendMessage(lastUserMessage.content, lastUserMessage.attachments);
|
||||
}
|
||||
}, [lastUserMessage, isStreaming, sendMessage]);
|
||||
|
||||
// Scroll to bottom on new messages, streaming progress, or interactive cards
|
||||
useEffect(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: 'LAST',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [
|
||||
messages.length,
|
||||
streamingEvents.length,
|
||||
isStreaming,
|
||||
pendingApproval,
|
||||
pendingClarification,
|
||||
]);
|
||||
|
||||
const followOutput = useCallback(
|
||||
(atBottom: boolean): false | 'smooth' =>
|
||||
atBottom || isStreaming ? 'smooth' : false,
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
const showStreamingSlot =
|
||||
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
|
||||
|
||||
if (messages.length === 0 && !showStreamingSlot) {
|
||||
return (
|
||||
<div className="ai-messages__empty">
|
||||
<div className="ai-empty__icon">
|
||||
<AIAssistantIcon size={40} />
|
||||
</div>
|
||||
<h3 className="ai-empty__title">SigNoz AI Assistant</h3>
|
||||
<p className="ai-empty__subtitle">
|
||||
Ask questions about your traces, logs, metrics, and infrastructure.
|
||||
</p>
|
||||
<div className="ai-empty__suggestions">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.text}
|
||||
type="button"
|
||||
className="ai-empty__chip"
|
||||
onClick={(): void => {
|
||||
sendMessage(s.text);
|
||||
}}
|
||||
>
|
||||
<s.icon size={14} />
|
||||
{s.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="ai-messages"
|
||||
totalCount={totalCount}
|
||||
followOutput={followOutput}
|
||||
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
|
||||
itemContent={(index): JSX.Element => {
|
||||
if (index < messages.length) {
|
||||
const msg = messages[index];
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' &&
|
||||
messages.slice(index + 1).every((m) => m.role !== 'assistant');
|
||||
return (
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
onRegenerate={
|
||||
isLastAssistant && !showStreamingSlot ? handleRegenerate : undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StreamingMessage
|
||||
conversationId={conversationId}
|
||||
events={streamingEvents}
|
||||
status={streamingStatus}
|
||||
pendingApproval={pendingApproval}
|
||||
pendingClarification={pendingClarification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Check, LoaderCircle, TriangleAlert, X, Zap } from '@signozhq/icons';
|
||||
|
||||
import { PageActionRegistry } from '../../pageActions/PageActionRegistry';
|
||||
import { AIActionBlock } from '../../pageActions/types';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
type BlockState = 'pending' | 'loading' | 'applied' | 'dismissed' | 'error';
|
||||
|
||||
/**
|
||||
* Renders an AI-suggested page action.
|
||||
*
|
||||
* Two modes based on the registered PageAction.autoApply flag:
|
||||
*
|
||||
* autoApply = false (default)
|
||||
* Shows a confirmation card with Accept / Dismiss. The user must
|
||||
* explicitly approve before execute() is called. Use for destructive or
|
||||
* hard-to-reverse actions (create dashboard, delete alert, etc.).
|
||||
*
|
||||
* autoApply = true
|
||||
* Executes immediately on mount — no card shown, just the result summary.
|
||||
* Use for low-risk, reversible actions where the user explicitly asked for
|
||||
* the change (e.g. "filter logs for errors"). Avoids unnecessary friction.
|
||||
*
|
||||
* Persists answered state via answeredBlocks so re-mounts don't reset UI.
|
||||
*/
|
||||
export default function ActionBlock({
|
||||
data,
|
||||
}: {
|
||||
data: AIActionBlock;
|
||||
}): JSX.Element {
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
|
||||
const [localState, setLocalState] = useState<BlockState>(() => {
|
||||
if (!messageId) {
|
||||
return 'pending';
|
||||
}
|
||||
const saved = answeredBlocks[messageId];
|
||||
if (!saved) {
|
||||
return 'pending';
|
||||
}
|
||||
if (saved === 'dismissed') {
|
||||
return 'dismissed';
|
||||
}
|
||||
if (saved.startsWith('error:')) {
|
||||
return 'error';
|
||||
}
|
||||
return 'applied';
|
||||
});
|
||||
const [resultSummary, setResultSummary] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const { actionId, description, parameters } = data;
|
||||
|
||||
// ── Shared execute logic ─────────────────────────────────────────────────────
|
||||
|
||||
const execute = async (): Promise<void> => {
|
||||
const action = PageActionRegistry.get(actionId);
|
||||
|
||||
if (!action) {
|
||||
const msg = `Action "${actionId}" is not available on the current page.`;
|
||||
setErrorMessage(msg);
|
||||
setLocalState('error');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `error:${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalState('loading');
|
||||
|
||||
try {
|
||||
const result = await action.execute(parameters as never);
|
||||
setResultSummary(result.summary);
|
||||
setLocalState('applied');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `applied:${result.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setErrorMessage(msg);
|
||||
setLocalState('error');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `error:${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Auto-apply: fire immediately on mount if the action opts in ──────────────
|
||||
|
||||
const autoApplyFired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-apply once, and only when the block hasn't been answered yet
|
||||
// (i.e. this is a fresh render, not a remount of an already-answered block).
|
||||
if (autoApplyFired.current || localState !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = PageActionRegistry.get(actionId);
|
||||
if (!action?.autoApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoApplyFired.current = true;
|
||||
execute();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
setLocalState('dismissed');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, 'dismissed');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Terminal states ──────────────────────────────────────────────────────────
|
||||
|
||||
if (localState === 'applied') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--applied">
|
||||
<Check
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--ok"
|
||||
/>
|
||||
<span className="ai-action__status-text">
|
||||
{resultSummary || 'Applied.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localState === 'dismissed') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--dismissed">
|
||||
<X
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--no"
|
||||
/>
|
||||
<span className="ai-action__status-text">Dismissed.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localState === 'error') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--error">
|
||||
<TriangleAlert
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--err"
|
||||
/>
|
||||
<span className="ai-action__status-text">{errorMessage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loading (autoApply in progress) ─────────────────────────────────────────
|
||||
|
||||
if (localState === 'loading') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--loading">
|
||||
<LoaderCircle
|
||||
size={13}
|
||||
className="ai-action__spinner ai-action__status-icon"
|
||||
/>
|
||||
<span className="ai-action__status-text">{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pending: manual confirmation card ────────────────────────────────────────
|
||||
|
||||
const paramEntries = Object.entries(parameters ?? {});
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-action">
|
||||
<div className="ai-action__header">
|
||||
<Zap size={13} className="ai-action__zap-icon" />
|
||||
<span className="ai-action__header-label">Suggested Action</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-action__description">{description}</p>
|
||||
|
||||
{paramEntries.length > 0 && (
|
||||
<ul className="ai-action__params">
|
||||
{paramEntries.map(([key, val]) => (
|
||||
<li key={key} className="ai-action__param">
|
||||
<span className="ai-action__param-key">{key}</span>
|
||||
<span className="ai-action__param-val">
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="ai-action__actions">
|
||||
<Button variant="solid" size="sm" onClick={execute}>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="outlined" size="sm" onClick={handleDismiss}>
|
||||
<X size={12} />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
|
||||
import { CHART_PALETTE, getChartTheme } from './chartSetup';
|
||||
|
||||
export interface BarDataset {
|
||||
label?: string;
|
||||
data: number[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface BarChartData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/**
|
||||
* Category labels (x-axis for vertical, y-axis for horizontal).
|
||||
* Shorthand: omit `datasets` and use `bars` for single-series data.
|
||||
*/
|
||||
labels?: string[];
|
||||
datasets?: BarDataset[];
|
||||
/** Single-series shorthand: [{ label, value }] */
|
||||
bars?: { label: string; value: number; color?: string }[];
|
||||
/** 'vertical' (default) | 'horizontal' */
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export default function BarChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: BarChartData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, direction = 'horizontal' } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
// Normalise shorthand `bars` → labels + datasets
|
||||
let labels: string[];
|
||||
let datasets: BarDataset[];
|
||||
|
||||
if (data.bars) {
|
||||
labels = data.bars.map((b) => b.label);
|
||||
datasets = [
|
||||
{
|
||||
label: title ?? 'Value',
|
||||
data: data.bars.map((b) => b.value),
|
||||
color: undefined, // use palette below
|
||||
},
|
||||
];
|
||||
} else {
|
||||
labels = data.labels ?? [];
|
||||
datasets = data.datasets ?? [];
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: datasets.map((ds, i) => {
|
||||
const baseColor = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
|
||||
return {
|
||||
label: ds.label ?? `Series ${i + 1}`,
|
||||
data: ds.data,
|
||||
backgroundColor: data.bars
|
||||
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
|
||||
: baseColor,
|
||||
borderColor: data.bars
|
||||
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
|
||||
: baseColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const barHeight = Math.max(160, labels.length * 28 + 48);
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div
|
||||
className="ai-chart__canvas-wrap"
|
||||
style={{ height: direction === 'horizontal' ? barHeight : 200 }}
|
||||
>
|
||||
<Bar
|
||||
data={chartData}
|
||||
options={{
|
||||
indexAxis: direction === 'horizontal' ? 'y' : 'x',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: unit
|
||||
? { label: (ctx): string => `${ctx.formattedValue} ${unit}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
callback:
|
||||
unit && direction !== 'horizontal'
|
||||
? (v): string => `${v} ${unit}`
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: { color: theme.tickColor, font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
|
||||
|
||||
/**
|
||||
* Global registry for AI response block renderers.
|
||||
*
|
||||
* Any part of the application can register a custom block type:
|
||||
*
|
||||
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
|
||||
* BlockRegistry.register('my-panel', MyPanelComponent);
|
||||
*
|
||||
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
|
||||
* JSON payload, and the registered component will be rendered in-place:
|
||||
*
|
||||
* ```ai-my-panel
|
||||
* { "foo": "bar" }
|
||||
* ```
|
||||
*/
|
||||
const _registry = new Map<string, BlockComponent>();
|
||||
|
||||
export const BlockRegistry = {
|
||||
register<T>(type: string, component: BlockComponent<T>): void {
|
||||
_registry.set(type, component as BlockComponent);
|
||||
},
|
||||
|
||||
get(type: string): BlockComponent | undefined {
|
||||
return _registry.get(type);
|
||||
},
|
||||
|
||||
/** Returns all registered type names (useful for debugging). */
|
||||
types(): string[] {
|
||||
return Array.from(_registry.keys());
|
||||
},
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
export interface ConfirmData {
|
||||
message?: string;
|
||||
/** Text sent back when accepted. Defaults to "Yes, proceed." */
|
||||
acceptText?: string;
|
||||
/** Text sent back when rejected. Defaults to "No, cancel." */
|
||||
rejectText?: string;
|
||||
/** Label shown on Accept button. Defaults to "Accept" */
|
||||
acceptLabel?: string;
|
||||
/** Label shown on Reject button. Defaults to "Reject" */
|
||||
rejectLabel?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmBlock({
|
||||
data,
|
||||
}: {
|
||||
data: ConfirmData;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
message,
|
||||
acceptText = 'Yes, proceed.',
|
||||
rejectText = 'No, cancel.',
|
||||
acceptLabel = 'Accept',
|
||||
rejectLabel = 'Reject',
|
||||
} = data;
|
||||
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
|
||||
// Durable answered state — survives re-renders/remounts
|
||||
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
|
||||
const isAnswered = answeredChoice !== undefined;
|
||||
|
||||
const handle = (choice: 'accepted' | 'rejected'): void => {
|
||||
const responseText = choice === 'accepted' ? acceptText : rejectText;
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, choice);
|
||||
}
|
||||
sendMessage(responseText);
|
||||
};
|
||||
|
||||
if (isAnswered) {
|
||||
const wasAccepted = answeredChoice === 'accepted';
|
||||
const icon = wasAccepted ? (
|
||||
<Check size={13} className="ai-confirm__icon ai-confirm__icon--ok" />
|
||||
) : (
|
||||
<X size={13} className="ai-confirm__icon ai-confirm__icon--no" />
|
||||
);
|
||||
return (
|
||||
<div className="ai-block ai-confirm ai-confirm--answered">
|
||||
{icon}
|
||||
<span className="ai-confirm__answer-text">
|
||||
{wasAccepted ? acceptText : rejectText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-confirm">
|
||||
{message && <p className="ai-confirm__message">{message}</p>}
|
||||
<div className="ai-confirm__actions">
|
||||
<Button variant="solid" size="sm" onClick={(): void => handle('accepted')}>
|
||||
<Check size={12} />
|
||||
{acceptLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={(): void => handle('rejected')}
|
||||
>
|
||||
<X size={12} />
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Checkbox, Radio } from 'antd';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface QuestionData {
|
||||
question?: string;
|
||||
type?: 'radio' | 'checkbox';
|
||||
options: (string | Option)[];
|
||||
}
|
||||
|
||||
function normalizeOption(opt: string | Option): Option {
|
||||
return typeof opt === 'string' ? { value: opt, label: opt } : opt;
|
||||
}
|
||||
|
||||
export default function InteractiveQuestion({
|
||||
data,
|
||||
}: {
|
||||
data: QuestionData;
|
||||
}): JSX.Element {
|
||||
const { question, type = 'radio', options } = data;
|
||||
const normalized = options.map(normalizeOption);
|
||||
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
|
||||
// Persist selected state locally only for the pending (not-yet-submitted) case
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
// Durable answered state from the store — survives re-renders/remounts
|
||||
const answeredText = messageId ? answeredBlocks[messageId] : undefined;
|
||||
const isAnswered = answeredText !== undefined;
|
||||
|
||||
const handleSubmit = (values: string[]): void => {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
const answer = values.join(', ');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, answer);
|
||||
}
|
||||
sendMessage(answer);
|
||||
};
|
||||
|
||||
if (isAnswered) {
|
||||
return (
|
||||
<div className="ai-block ai-question ai-question--answered">
|
||||
<span className="ai-question__check">✓</span>
|
||||
<span className="ai-question__answer-text">{answeredText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-question">
|
||||
{question && <p className="ai-block__title">{question}</p>}
|
||||
|
||||
{type === 'radio' ? (
|
||||
<Radio.Group
|
||||
className="ai-question__options"
|
||||
onChange={(e): void => {
|
||||
setSelected([e.target.value]);
|
||||
handleSubmit([e.target.value]);
|
||||
}}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Radio key={opt.value} value={opt.value} className="ai-question__option">
|
||||
{opt.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox.Group
|
||||
className="ai-question__options ai-question__options--checkbox"
|
||||
onChange={(vals): void => setSelected(vals as string[])}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="ai-question__option"
|
||||
>
|
||||
{opt.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="ai-question__submit"
|
||||
disabled={selected.length === 0}
|
||||
onClick={(): void => handleSubmit(selected)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
import {
|
||||
CHART_PALETTE,
|
||||
CHART_PALETTE_ALPHA,
|
||||
getChartTheme,
|
||||
} from './chartSetup';
|
||||
|
||||
export interface LineDataset {
|
||||
label?: string;
|
||||
data: number[];
|
||||
color?: string;
|
||||
/** Fill area under line. Defaults to false. */
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export interface LineChartData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/** X-axis labels (time strings, numbers, etc.) */
|
||||
labels: string[];
|
||||
datasets: LineDataset[];
|
||||
}
|
||||
|
||||
export default function LineChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: LineChartData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, labels, datasets } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: datasets.map((ds, i) => {
|
||||
const color = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
|
||||
const fillColor = CHART_PALETTE_ALPHA[i % CHART_PALETTE_ALPHA.length];
|
||||
return {
|
||||
label: ds.label ?? `Series ${i + 1}`,
|
||||
data: ds.data,
|
||||
borderColor: color,
|
||||
backgroundColor: ds.fill ? fillColor : 'transparent',
|
||||
pointBackgroundColor: color,
|
||||
pointRadius: labels.length > 30 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: 2,
|
||||
fill: ds.fill ?? false,
|
||||
tension: 0.35,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div className="ai-chart__canvas-wrap">
|
||||
<Line
|
||||
data={chartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: unit
|
||||
? { label: (ctx): string => ` ${ctx.formattedValue} ${unit}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
callback: unit ? (v): string => `${v} ${unit}` : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
|
||||
import { CHART_PALETTE, getChartTheme } from './chartSetup';
|
||||
|
||||
export interface SliceData {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface PieChartData {
|
||||
title?: string;
|
||||
slices: SliceData[];
|
||||
}
|
||||
|
||||
export default function PieChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: PieChartData;
|
||||
}): JSX.Element {
|
||||
const { title, slices } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
const chartData = {
|
||||
labels: slices.map((s) => s.label),
|
||||
datasets: [
|
||||
{
|
||||
data: slices.map((s) => s.value),
|
||||
backgroundColor: slices.map(
|
||||
(s, i) => s.color ?? CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
),
|
||||
borderColor: theme.tooltipBg,
|
||||
borderWidth: 2,
|
||||
hoverOffset: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div className="ai-chart__canvas-wrap ai-chart__canvas-wrap--pie">
|
||||
<Doughnut
|
||||
data={chartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '58%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: theme.legendColor,
|
||||
boxWidth: 10,
|
||||
padding: 10,
|
||||
font: { size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx): string => {
|
||||
const total = (ctx.dataset.data as number[]).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
const pct = ((ctx.parsed / total) * 100).toFixed(1);
|
||||
return ` ${ctx.formattedValue} (${pct}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { BlockRegistry } from './BlockRegistry';
|
||||
|
||||
interface CodeProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
// react-markdown passes `node` — accept and ignore it
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
node?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for react-markdown's `code` renderer.
|
||||
*
|
||||
* When the language tag begins with `ai-` the remaining part is looked up in
|
||||
* the BlockRegistry and, if a component is found, the JSON payload is parsed
|
||||
* and the component is rendered.
|
||||
*
|
||||
* Falls back to a regular <code> element for all other blocks (including plain
|
||||
* inline code and unknown `ai-*` types).
|
||||
*/
|
||||
export default function RichCodeBlock({
|
||||
className,
|
||||
children,
|
||||
}: CodeProps): JSX.Element {
|
||||
const lang = /language-(\S+)/.exec(className ?? '')?.[1];
|
||||
|
||||
if (lang?.startsWith('ai-')) {
|
||||
const blockType = lang.slice(3); // strip the 'ai-' prefix
|
||||
const BlockComp = BlockRegistry.get(blockType);
|
||||
|
||||
if (BlockComp) {
|
||||
const raw = String(children ?? '').trim();
|
||||
try {
|
||||
const parsedData = JSON.parse(raw);
|
||||
return <BlockComp data={parsedData} />;
|
||||
} catch {
|
||||
// Invalid JSON — fall through and render as a code block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
export interface TimeseriesData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/** Column header labels. Defaults to ["Time", "Value"]. */
|
||||
columns?: string[];
|
||||
/** Each row is an array of cell values (strings or numbers). */
|
||||
rows: (string | number)[][];
|
||||
}
|
||||
|
||||
export default function TimeseriesBlock({
|
||||
data,
|
||||
}: {
|
||||
data: TimeseriesData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, columns, rows } = data;
|
||||
const cols = columns ?? ['Time', 'Value'];
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-timeseries">
|
||||
{(title || unit) && (
|
||||
<p className="ai-block__title">
|
||||
{title}
|
||||
{unit ? <span className="ai-block__unit"> ({unit})</span> : null}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="ai-timeseries__scroll">
|
||||
<table className="ai-timeseries__table">
|
||||
<thead>
|
||||
<tr>
|
||||
{cols.map((col) => (
|
||||
<th key={col}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
// Row index is the stable key here since rows have no IDs
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<td key={j}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && <p className="ai-block__empty">No data available.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Registers all Chart.js components that the AI assistant blocks need.
|
||||
* Import this module once (via blocks/index.ts) — safe to import multiple times.
|
||||
*/
|
||||
import {
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
BarElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Filler,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
// ─── Colour palette (SigNoz brand colours as explicit hex) ───────────────────
|
||||
|
||||
export const CHART_PALETTE = [
|
||||
'#4E74F8', // robin (blue primary)
|
||||
'#2DB699', // aquamarine
|
||||
'#F5A623', // amber
|
||||
'#F05944', // cherry (red)
|
||||
'#06B6D4', // aqua (cyan)
|
||||
'#F97316', // sienna (orange)
|
||||
'#8B5CF6', // violet
|
||||
'#EC4899', // sakura (pink)
|
||||
];
|
||||
|
||||
export const CHART_PALETTE_ALPHA = CHART_PALETTE.map((c) => `${c}33`); // 20% opacity fills
|
||||
|
||||
// ─── Theme helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function isDark(): boolean {
|
||||
return document.body.classList.contains('dark');
|
||||
}
|
||||
|
||||
export function getChartTheme(): {
|
||||
gridColor: string;
|
||||
tickColor: string;
|
||||
legendColor: string;
|
||||
tooltipBg: string;
|
||||
tooltipText: string;
|
||||
} {
|
||||
const dark = isDark();
|
||||
return {
|
||||
gridColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)',
|
||||
tickColor: dark ? '#8c9bb5' : '#6b7280',
|
||||
legendColor: dark ? '#c0cbe0' : '#374151',
|
||||
tooltipBg: dark ? '#1a1f2e' : '#ffffff',
|
||||
tooltipText: dark ? '#e2e8f0' : '#111827',
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* AI response block system.
|
||||
*
|
||||
* Import this module once (e.g. in MessageBubble / StreamingMessage) to
|
||||
* register all built-in block types. External modules can extend the registry
|
||||
* at any time:
|
||||
*
|
||||
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
|
||||
* BlockRegistry.register('my-panel', MyPanelComponent);
|
||||
*/
|
||||
|
||||
// Side-effect: ensure Chart.js components are registered before any chart renders
|
||||
import './chartSetup';
|
||||
|
||||
import ActionBlock from './ActionBlock';
|
||||
import BarChartBlock from './BarChartBlock';
|
||||
import { BlockRegistry } from './BlockRegistry';
|
||||
import ConfirmBlock from './ConfirmBlock';
|
||||
import InteractiveQuestion from './InteractiveQuestion';
|
||||
import LineChartBlock from './LineChartBlock';
|
||||
import PieChartBlock from './PieChartBlock';
|
||||
import TimeseriesBlock from './TimeseriesBlock';
|
||||
|
||||
// ─── Register built-in block types ───────────────────────────────────────────
|
||||
|
||||
BlockRegistry.register('question', InteractiveQuestion);
|
||||
BlockRegistry.register('confirm', ConfirmBlock);
|
||||
BlockRegistry.register('timeseries', TimeseriesBlock);
|
||||
BlockRegistry.register('barchart', BarChartBlock);
|
||||
BlockRegistry.register('piechart', PieChartBlock);
|
||||
// ai-linechart and ai-graph are aliases for the same component
|
||||
BlockRegistry.register('linechart', LineChartBlock);
|
||||
BlockRegistry.register('graph', LineChartBlock);
|
||||
// Page-aware action block
|
||||
BlockRegistry.register('action', ActionBlock);
|
||||
|
||||
// ─── Public exports ───────────────────────────────────────────────────────────
|
||||
|
||||
export { BlockRegistry } from './BlockRegistry';
|
||||
export { default as RichCodeBlock } from './RichCodeBlock';
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// ── Web Speech API types (not yet in lib.dom.d.ts) ────────────────────────────
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
readonly isFinal: boolean;
|
||||
[index: number]: { transcript: string; confidence: number };
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly resultIndex: number;
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface ISpeechRecognition extends EventTarget {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onstart: (() => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type SpeechRecognitionConstructor = new () => ISpeechRecognition;
|
||||
|
||||
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
typeof window !== 'undefined'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).SpeechRecognition ??
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null
|
||||
: null;
|
||||
|
||||
export type SpeechRecognitionError =
|
||||
| 'not-supported'
|
||||
| 'not-allowed'
|
||||
| 'no-speech'
|
||||
| 'network'
|
||||
| 'unknown';
|
||||
|
||||
interface UseSpeechRecognitionOptions {
|
||||
onError?: (error: SpeechRecognitionError) => void;
|
||||
/**
|
||||
* Called directly from browser recognition events — no React state intermediary.
|
||||
* `isFinal=false` → interim (live preview), `isFinal=true` → committed text.
|
||||
*/
|
||||
onTranscript?: (text: string, isFinal: boolean) => void;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface UseSpeechRecognitionReturn {
|
||||
isListening: boolean;
|
||||
isSupported: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
/** Stop recognition and discard any pending interim text (no onTranscript call). */
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useSpeechRecognition({
|
||||
onError,
|
||||
onTranscript,
|
||||
lang = 'en-US',
|
||||
}: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<ISpeechRecognition | null>(null);
|
||||
const isDiscardingRef = useRef(false);
|
||||
const isSupported = SpeechRecognitionAPI !== null;
|
||||
|
||||
// Always-current refs — updated synchronously on every render so closures
|
||||
// inside recognition event handlers always call the latest version.
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
onTranscriptRef.current = onTranscript;
|
||||
|
||||
const stop = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
isDiscardingRef.current = true;
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const start = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
onErrorRef.current?.('not-supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// If already listening, stop
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const recognition = new SpeechRecognitionAPI!();
|
||||
recognition.lang = lang;
|
||||
recognition.continuous = true; // keep listening until user clicks stop
|
||||
recognition.interimResults = true; // live updates while speaking
|
||||
|
||||
// Track the last interim text so we can commit it as final in onend —
|
||||
// Chrome often skips the isFinal result when stop() is called manually.
|
||||
let pendingInterim = '';
|
||||
|
||||
recognition.onstart = (): void => {
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
recognition.onresult = (event): void => {
|
||||
let interim = '';
|
||||
let finalText = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const text = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalText += text;
|
||||
} else {
|
||||
interim += text;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalText) {
|
||||
pendingInterim = '';
|
||||
onTranscriptRef.current?.(finalText, true);
|
||||
} else if (interim) {
|
||||
pendingInterim = interim;
|
||||
onTranscriptRef.current?.(interim, false);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event): void => {
|
||||
pendingInterim = '';
|
||||
let mapped: SpeechRecognitionError = 'unknown';
|
||||
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
||||
mapped = 'not-allowed';
|
||||
} else if (event.error === 'no-speech') {
|
||||
mapped = 'no-speech';
|
||||
} else if (event.error === 'network') {
|
||||
mapped = 'network';
|
||||
}
|
||||
onErrorRef.current?.(mapped);
|
||||
};
|
||||
|
||||
recognition.onend = (): void => {
|
||||
// Commit any interim text that never received a final result,
|
||||
// unless the session was explicitly discarded.
|
||||
if (!isDiscardingRef.current && pendingInterim) {
|
||||
const committed = pendingInterim;
|
||||
pendingInterim = '';
|
||||
onTranscriptRef.current?.(committed, true);
|
||||
}
|
||||
isDiscardingRef.current = false;
|
||||
pendingInterim = '';
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}, [isSupported, lang]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
recognitionRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { isListening, isSupported, start, stop, discard };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
import { PageAction, PageActionDescriptor } from './types';
|
||||
|
||||
/**
|
||||
* Module-level singleton (mirrors BlockRegistry) that maps action IDs to their
|
||||
* PageAction descriptors. Pages register their actions on mount and unregister
|
||||
* on unmount via `usePageActions`.
|
||||
*
|
||||
* Internal structure:
|
||||
* _byPage: pageId → PageAction[] (for batch unregister)
|
||||
* _byId: actionId → PageAction (O(1) lookup at execute time)
|
||||
*/
|
||||
|
||||
// pageId → actions[]
|
||||
const _byPage = new Map<string, PageAction[]>();
|
||||
|
||||
// actionId → action (flat, for O(1) lookup)
|
||||
const _byId = new Map<string, PageAction>();
|
||||
|
||||
export const PageActionRegistry = {
|
||||
/**
|
||||
* Register a set of actions under a page scope key.
|
||||
* Calling register() again with the same pageId replaces the previous set.
|
||||
*/
|
||||
register(pageId: string, actions: PageAction[]): void {
|
||||
// Remove any previously registered actions for this page
|
||||
const prev = _byPage.get(pageId) ?? [];
|
||||
prev.forEach((a) => _byId.delete(a.id));
|
||||
|
||||
_byPage.set(pageId, actions);
|
||||
actions.forEach((a) => _byId.set(a.id, a));
|
||||
},
|
||||
|
||||
/** Remove all actions registered under a page scope key. */
|
||||
unregister(pageId: string): void {
|
||||
const prev = _byPage.get(pageId) ?? [];
|
||||
prev.forEach((a) => _byId.delete(a.id));
|
||||
_byPage.delete(pageId);
|
||||
},
|
||||
|
||||
/** Look up a single action by its dot-namespaced id. */
|
||||
get(actionId: string): PageAction | undefined {
|
||||
return _byId.get(actionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns serialisable descriptors for all currently registered actions,
|
||||
* with context snapshots already collected. Safe to embed in API payload.
|
||||
*/
|
||||
snapshot(): PageActionDescriptor[] {
|
||||
return Array.from(_byId.values()).map((action) => ({
|
||||
id: action.id,
|
||||
description: action.description,
|
||||
parameters: action.parameters,
|
||||
context: action.getContext?.(),
|
||||
}));
|
||||
},
|
||||
|
||||
/** Returns all registered action IDs (useful for debugging). */
|
||||
ids(): string[] {
|
||||
return Array.from(_byId.keys());
|
||||
},
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
export type JSONSchemaProperty =
|
||||
| { type: 'string'; description?: string; enum?: string[] }
|
||||
| { type: 'number'; description?: string }
|
||||
| { type: 'boolean'; description?: string }
|
||||
| {
|
||||
type: 'array';
|
||||
description?: string;
|
||||
items: JSONSchemaProperty | JSONSchemaObject;
|
||||
}
|
||||
| {
|
||||
type: 'object';
|
||||
description?: string;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
export type JSONSchemaObject = {
|
||||
type: 'object';
|
||||
properties: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
export interface ActionResult {
|
||||
/** Short human-readable outcome shown after the action completes. */
|
||||
summary: string;
|
||||
/** Optional structured data the block can display. */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a single action a page exposes to the AI Assistant.
|
||||
* Pages register these via `usePageActions`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface PageAction<TParams = Record<string, any>> {
|
||||
/**
|
||||
* Stable dot-namespaced identifier — e.g. "logs.runQuery", "dashboard.create".
|
||||
* The AI uses this to target the correct action.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Natural-language description sent in the PAGE_CONTEXT block.
|
||||
* The AI uses this to decide which action to invoke.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* JSON Schema (draft-07) describing the parameters accepted by this action.
|
||||
* Sent to the AI so it can generate structurally valid calls.
|
||||
*/
|
||||
parameters: JSONSchemaObject;
|
||||
|
||||
/**
|
||||
* Executes the action. Resolves with a result summary on success.
|
||||
* Rejects with an Error if the action cannot be completed.
|
||||
*/
|
||||
execute: (params: TParams) => Promise<ActionResult>;
|
||||
|
||||
/**
|
||||
* When true, ActionBlock executes the action immediately on mount without
|
||||
* showing a confirmation card. Use for low-risk, reversible actions where
|
||||
* the user explicitly requested the change (e.g. updating a query filter).
|
||||
* Default: false (shows Accept / Dismiss card).
|
||||
*/
|
||||
autoApply?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: returns a snapshot of the current page state to include in
|
||||
* the PAGE_CONTEXT block. Called fresh at message-send time.
|
||||
*/
|
||||
getContext?: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisable version of PageAction (no function references).
|
||||
* Safe to embed in the API payload.
|
||||
*/
|
||||
export interface PageActionDescriptor {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
/** Context snapshot returned by PageAction.getContext() */
|
||||
context?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON payload the AI emits inside an ```ai-action``` fenced block
|
||||
* when it wants to invoke an action.
|
||||
*/
|
||||
export interface AIActionBlock {
|
||||
/** Must match a registered PageAction.id */
|
||||
actionId: string;
|
||||
/** One-sentence explanation shown in the confirmation card. */
|
||||
description: string;
|
||||
/** Parameters chosen by the AI — validated against the action's JSON Schema. */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { PageActionRegistry } from './PageActionRegistry';
|
||||
import { PageAction } from './types';
|
||||
|
||||
/**
|
||||
* Registers page-specific actions into the PageActionRegistry for the lifetime
|
||||
* of the calling component. Cleanup (unregister) happens automatically on unmount.
|
||||
*
|
||||
* Usage:
|
||||
* const actions = useMemo(() => [
|
||||
* logsRunQueryAction({ handleRunQuery, ... }),
|
||||
* ], [handleRunQuery, ...]);
|
||||
*
|
||||
* usePageActions('logs-explorer', actions);
|
||||
*
|
||||
* IMPORTANT: memoize the `actions` array with useMemo so that the reference
|
||||
* stays stable and we don't re-register on every render.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function usePageActions(
|
||||
pageId: string,
|
||||
actions: PageAction<any>[],
|
||||
): void {
|
||||
useEffect(() => {
|
||||
PageActionRegistry.register(pageId, actions);
|
||||
return (): void => {
|
||||
PageActionRegistry.unregister(pageId);
|
||||
};
|
||||
// Re-register when actions reference changes (e.g. new callbacks after store update)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageId, actions]);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AIAssistantStore } from './useAIAssistantStore';
|
||||
|
||||
export type AIAssistantStoreState = AIAssistantStore;
|
||||
|
||||
/**
|
||||
* Number of conversations whose execution is waiting on the user (approval or
|
||||
* clarification). Used for header badges when the side panel is closed.
|
||||
*/
|
||||
export function selectPendingUserInputStreamCount(
|
||||
state: AIAssistantStoreState,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (const st of Object.values(state.streams)) {
|
||||
const { streamingStatus } = st;
|
||||
if (
|
||||
streamingStatus === 'awaiting_approval' ||
|
||||
streamingStatus === 'awaiting_clarification'
|
||||
) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +0,0 @@
|
||||
export interface MessageAttachment {
|
||||
name: string;
|
||||
type: string;
|
||||
/** data URI for images, or a download URL for other files */
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
export type MessageRole = 'user' | 'assistant';
|
||||
|
||||
export type ActionKind =
|
||||
| 'follow_up'
|
||||
| 'open_resource'
|
||||
| 'navigate'
|
||||
| 'apply_filter'
|
||||
| 'open_docs'
|
||||
| 'undo'
|
||||
| 'revert';
|
||||
|
||||
export interface AssistantAction {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: ActionKind;
|
||||
payload: Record<string, unknown>;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export type FeedbackRating = 'positive' | 'negative';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message blocks — ordered content blocks for assistant replies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ToolCallBlock {
|
||||
type: 'tool_call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
result?: unknown;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export type MessageBlock = TextBlock | ThinkingBlock | ToolCallBlock;
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
attachments?: MessageAttachment[];
|
||||
/** Ordered content blocks for structured rendering of assistant replies. */
|
||||
blocks?: MessageBlock[];
|
||||
/** Suggested follow-up actions returned by the assistant (final message only). */
|
||||
actions?: AssistantAction[];
|
||||
/** Persisted feedback rating — set after user votes and the API confirms. */
|
||||
feedbackRating?: FeedbackRating | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
/** Opaque thread ID assigned by the backend after first message. */
|
||||
threadId?: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
title?: string;
|
||||
/** When true, thread is hidden from the main list and shown under Archived. */
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming-only types — live during an active SSE stream, never persisted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single tool invocation tracked during streaming. */
|
||||
export interface StreamingToolCall {
|
||||
/** Matches the toolName field in SSE tool_call / tool_result events. */
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
/** True once the corresponding tool_result event has been received. */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ordered item in the streaming event timeline.
|
||||
* Text and tool calls are interleaved in arrival order so the UI renders
|
||||
* them chronologically rather than grouping all tools above all text.
|
||||
*/
|
||||
export type StreamingEventItem =
|
||||
| { kind: 'text'; content: string }
|
||||
| { kind: 'thinking'; content: string }
|
||||
| { kind: 'tool'; toolCall: StreamingToolCall };
|
||||
|
||||
/** Data from an SSE `approval` event — user must approve or reject before the stream continues. */
|
||||
export interface PendingApproval {
|
||||
approvalId: string;
|
||||
executionId: string;
|
||||
actionType: string;
|
||||
resourceType: string;
|
||||
summary: string;
|
||||
diff: { before: unknown; after: unknown } | null;
|
||||
}
|
||||
|
||||
/** A single field in a clarification form. */
|
||||
export interface ClarificationField {
|
||||
id: string;
|
||||
/** 'text' | 'number' | 'select' | 'checkbox' | 'radio' */
|
||||
type: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[] | null;
|
||||
default?: string | string[] | null;
|
||||
}
|
||||
|
||||
/** Data from an SSE `clarification` event — user must submit answers before the stream continues. */
|
||||
export interface PendingClarification {
|
||||
clarificationId: string;
|
||||
executionId: string;
|
||||
message: string;
|
||||
discoveredContext: Record<string, unknown> | null;
|
||||
fields: ClarificationField[];
|
||||
}
|
||||
|
||||
/** Per-conversation streaming state. Present in the store's `streams` map only while active. */
|
||||
export interface ConversationStreamState {
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingStatus: string;
|
||||
streamingEvents: StreamingEventItem[];
|
||||
streamingMessageId: string | null;
|
||||
pendingApproval: PendingApproval | null;
|
||||
pendingClarification: PendingClarification | null;
|
||||
}
|
||||
@@ -34,13 +34,13 @@ describe('Alert Channels Settings List page', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
describe('Should display the Alert Channels page properly', () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible', () => {
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if "New Alert Channel" Button is visble ', () => {
|
||||
it('Should check if "New Alert Channel" Button is visble', () => {
|
||||
expect(screen.getByText('button_new_channel')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if the help icon is visible and displays "tooltip_notification_channels ', async () => {
|
||||
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
|
||||
const helpIcon = screen.getByLabelText('question-circle');
|
||||
|
||||
fireEvent.mouseOver(helpIcon);
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('Alert Channels Settings List page (Normal User)', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
describe('Should display the Alert Channels page properly', () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => {
|
||||
it('Should check if "The alerts will be sent to all the configured channels." is visible', async () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
|
||||
);
|
||||
@@ -51,7 +51,7 @@ describe('Alert Channels Settings List page (Normal User)', () => {
|
||||
await waitFor(() => expect(newAlertButton).toBeInTheDocument());
|
||||
expect(newAlertButton).toBeDisabled();
|
||||
});
|
||||
it('Should check if the help icon is visible and displays "tooltip_notification_channels ', async () => {
|
||||
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
|
||||
const helpIcon = screen.getByLabelText('question-circle');
|
||||
fireEvent.mouseOver(helpIcon);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('Create Alert Channel', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel ', () => {
|
||||
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
|
||||
beforeEach(() => {
|
||||
render(<CreateAlertChannels preType={ChannelType.Slack} />);
|
||||
});
|
||||
@@ -54,13 +54,13 @@ describe('Create Alert Channel', () => {
|
||||
it('Should check if the title is "New Notification Channels"', () => {
|
||||
expect(screen.getByText('page_title_create')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if the name label and textbox are displayed properly ', () => {
|
||||
it('Should check if the name label and textbox are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_channel_name',
|
||||
testId: 'channel-name-textbox',
|
||||
});
|
||||
});
|
||||
it('Should check if Send resolved alerts label and checkbox are displayed properly ', () => {
|
||||
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_send_resolved',
|
||||
testId: 'field-send-resolved-checkbox',
|
||||
@@ -76,13 +76,13 @@ describe('Create Alert Channel', () => {
|
||||
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
|
||||
expect(screen.getByText('Slack')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if Webhook URL label and input are displayed properly ', () => {
|
||||
it('Should check if Webhook URL label and input are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_webhook_url',
|
||||
testId: 'webhook-url-textbox',
|
||||
});
|
||||
});
|
||||
it('Should check if Recepient label, input, and help text are displayed properly ', () => {
|
||||
it('Should check if Recepient label, input, and help text are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_slack_recipient',
|
||||
testId: 'slack-channel-textbox',
|
||||
@@ -90,7 +90,7 @@ describe('Create Alert Channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Should check if Title label and text area are displayed properly ', () => {
|
||||
it('Should check if Title label and text area are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_slack_title',
|
||||
testId: 'title-textarea',
|
||||
@@ -101,7 +101,7 @@ describe('Create Alert Channel', () => {
|
||||
|
||||
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
|
||||
});
|
||||
it('Should check if Description label and text area are displayed properly ', () => {
|
||||
it('Should check if Description label and text area are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_slack_description',
|
||||
testId: 'description-textarea',
|
||||
@@ -177,13 +177,13 @@ describe('Create Alert Channel', () => {
|
||||
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
|
||||
expect(screen.getByText('Webhook')).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if Webhook URL label and input are displayed properly ', () => {
|
||||
it('Should check if Webhook URL label and input are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_webhook_url',
|
||||
testId: 'webhook-url-textbox',
|
||||
});
|
||||
});
|
||||
it('Should check if Webhook User Name label, input, and help text are displayed properly ', () => {
|
||||
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_webhook_username',
|
||||
testId: 'webhook-username-textbox',
|
||||
@@ -321,7 +321,7 @@ describe('Create Alert Channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Should check if Message contains the default template ', () => {
|
||||
it('Should check if Message contains the default template', () => {
|
||||
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
|
||||
|
||||
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
|
||||
@@ -387,14 +387,14 @@ describe('Create Alert Channel', () => {
|
||||
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if Webhook URL label and input are displayed properly ', () => {
|
||||
it('Should check if Webhook URL label and input are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_webhook_url',
|
||||
testId: 'webhook-url-textbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should check if Title label and text area are displayed properly ', () => {
|
||||
it('Should check if Title label and text area are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_slack_title',
|
||||
testId: 'title-textarea',
|
||||
@@ -406,7 +406,7 @@ describe('Create Alert Channel', () => {
|
||||
|
||||
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
|
||||
});
|
||||
it('Should check if Description label and text area are displayed properly ', () => {
|
||||
it('Should check if Description label and text area are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_slack_description',
|
||||
testId: 'description-textarea',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user