mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-14 14:10:32 +01:00
Compare commits
33 Commits
alertmanag
...
platform-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f1332169 | ||
|
|
6736957074 | ||
|
|
4878757c4c | ||
|
|
4f263304f0 | ||
|
|
2680f7163f | ||
|
|
edb30f29c1 | ||
|
|
d6f4b051e6 | ||
|
|
7bc6ce7551 | ||
|
|
3b9ee4901e | ||
|
|
2517f69b65 | ||
|
|
5363dc6b0e | ||
|
|
83fa73c3e8 | ||
|
|
59a757f9bb | ||
|
|
16267e3172 | ||
|
|
d35db1b02b | ||
|
|
0556e67739 | ||
|
|
79ea20b371 | ||
|
|
2bbd11c181 | ||
|
|
0f9891d1c1 | ||
|
|
b236a29a99 | ||
|
|
828459ab30 | ||
|
|
b572e30045 | ||
|
|
f1ce804629 | ||
|
|
d15065b808 | ||
|
|
757c4e8ea9 | ||
|
|
b55c009c31 | ||
|
|
9cb6228da5 | ||
|
|
951f55b062 | ||
|
|
42ef704077 | ||
|
|
515220194d | ||
|
|
ac5ccbf186 | ||
|
|
49bfb01f4c | ||
|
|
7f6bdcbb8c |
@@ -66,10 +66,9 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
registry := coretypes.NewRegistry()
|
||||
|
||||
allowedResources := map[string]bool{
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourcesRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:22-bookworm AS build
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
RUN CI=1 npm i -g pnpm
|
||||
RUN CI=1 npm i -g pnpm@10
|
||||
RUN CI=1 pnpm install
|
||||
RUN CI=1 pnpm build
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.3
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -449,6 +449,7 @@ components:
|
||||
- list
|
||||
- assignee
|
||||
- attach
|
||||
- detach
|
||||
type: string
|
||||
AuthtypesRole:
|
||||
properties:
|
||||
@@ -2206,7 +2207,7 @@ components:
|
||||
- role
|
||||
- organization
|
||||
- metaresource
|
||||
- metaresources
|
||||
- telemetryresource
|
||||
type: string
|
||||
DashboardtypesDashboard:
|
||||
properties:
|
||||
@@ -2579,6 +2580,76 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDeploymentRecord:
|
||||
properties:
|
||||
availablePods:
|
||||
type: integer
|
||||
deploymentCPU:
|
||||
format: double
|
||||
type: number
|
||||
deploymentCPULimit:
|
||||
format: double
|
||||
type: number
|
||||
deploymentCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
deploymentMemory:
|
||||
format: double
|
||||
type: number
|
||||
deploymentMemoryLimit:
|
||||
format: double
|
||||
type: number
|
||||
deploymentMemoryRequest:
|
||||
format: double
|
||||
type: number
|
||||
deploymentName:
|
||||
type: string
|
||||
desiredPods:
|
||||
type: integer
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
required:
|
||||
- deploymentName
|
||||
- deploymentCPU
|
||||
- deploymentCPURequest
|
||||
- deploymentCPULimit
|
||||
- deploymentMemory
|
||||
- deploymentMemoryRequest
|
||||
- deploymentMemoryLimit
|
||||
- desiredPods
|
||||
- availablePods
|
||||
- podCountsByPhase
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDeployments:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
|
||||
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
|
||||
InframonitoringtypesHostFilter:
|
||||
properties:
|
||||
expression:
|
||||
@@ -2658,6 +2729,82 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesJobRecord:
|
||||
properties:
|
||||
activePods:
|
||||
type: integer
|
||||
desiredSuccessfulPods:
|
||||
type: integer
|
||||
failedPods:
|
||||
type: integer
|
||||
jobCPU:
|
||||
format: double
|
||||
type: number
|
||||
jobCPULimit:
|
||||
format: double
|
||||
type: number
|
||||
jobCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
jobMemory:
|
||||
format: double
|
||||
type: number
|
||||
jobMemoryLimit:
|
||||
format: double
|
||||
type: number
|
||||
jobMemoryRequest:
|
||||
format: double
|
||||
type: number
|
||||
jobName:
|
||||
type: string
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
successfulPods:
|
||||
type: integer
|
||||
required:
|
||||
- jobName
|
||||
- jobCPU
|
||||
- jobCPURequest
|
||||
- jobCPULimit
|
||||
- jobMemory
|
||||
- jobMemoryRequest
|
||||
- jobMemoryLimit
|
||||
- desiredSuccessfulPods
|
||||
- activePods
|
||||
- failedPods
|
||||
- successfulPods
|
||||
- podCountsByPhase
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesJobs:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
|
||||
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
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
meta:
|
||||
@@ -2909,6 +3056,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableDeployments:
|
||||
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
|
||||
InframonitoringtypesPostableHosts:
|
||||
properties:
|
||||
end:
|
||||
@@ -2935,6 +3108,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableJobs:
|
||||
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
|
||||
InframonitoringtypesPostableNamespaces:
|
||||
properties:
|
||||
end:
|
||||
@@ -3013,6 +3212,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableStatefulSets:
|
||||
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
|
||||
InframonitoringtypesPostableVolumes:
|
||||
properties:
|
||||
end:
|
||||
@@ -3054,6 +3279,76 @@ components:
|
||||
- list
|
||||
- grouped_list
|
||||
type: string
|
||||
InframonitoringtypesStatefulSetRecord:
|
||||
properties:
|
||||
currentPods:
|
||||
type: integer
|
||||
desiredPods:
|
||||
type: integer
|
||||
meta:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
statefulSetCPU:
|
||||
format: double
|
||||
type: number
|
||||
statefulSetCPULimit:
|
||||
format: double
|
||||
type: number
|
||||
statefulSetCPURequest:
|
||||
format: double
|
||||
type: number
|
||||
statefulSetMemory:
|
||||
format: double
|
||||
type: number
|
||||
statefulSetMemoryLimit:
|
||||
format: double
|
||||
type: number
|
||||
statefulSetMemoryRequest:
|
||||
format: double
|
||||
type: number
|
||||
statefulSetName:
|
||||
type: string
|
||||
required:
|
||||
- statefulSetName
|
||||
- statefulSetCPU
|
||||
- statefulSetCPURequest
|
||||
- statefulSetCPULimit
|
||||
- statefulSetMemory
|
||||
- statefulSetMemoryRequest
|
||||
- statefulSetMemoryLimit
|
||||
- desiredPods
|
||||
- currentPods
|
||||
- podCountsByPhase
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesStatefulSets:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
|
||||
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
|
||||
InframonitoringtypesVolumeRecord:
|
||||
properties:
|
||||
meta:
|
||||
@@ -9002,9 +9297,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:list
|
||||
summary: List roles
|
||||
tags:
|
||||
- role
|
||||
@@ -9076,9 +9371,9 @@ paths:
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:create
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:create
|
||||
summary: Create role
|
||||
tags:
|
||||
- role
|
||||
@@ -9138,9 +9433,9 @@ paths:
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:delete
|
||||
summary: Delete role
|
||||
tags:
|
||||
- role
|
||||
@@ -9189,9 +9484,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:read
|
||||
summary: Get role
|
||||
tags:
|
||||
- role
|
||||
@@ -9255,9 +9550,9 @@ paths:
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:update
|
||||
summary: Patch role
|
||||
tags:
|
||||
- role
|
||||
@@ -9333,9 +9628,9 @@ paths:
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:read
|
||||
summary: Get objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
@@ -9411,9 +9706,9 @@ paths:
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- role:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- role:update
|
||||
summary: Patch objects for a role by relation
|
||||
tags:
|
||||
- role
|
||||
@@ -10017,9 +10312,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:read
|
||||
- factor-api-key:list
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
- factor-api-key:list
|
||||
summary: List service account keys
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -10085,9 +10380,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- factor-api-key:create
|
||||
- serviceaccount:attach
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- factor-api-key:create
|
||||
- serviceaccount:attach
|
||||
summary: Create a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -10140,9 +10437,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- factor-api-key:delete
|
||||
- serviceaccount:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- factor-api-key:delete
|
||||
- serviceaccount:detach
|
||||
summary: Revoke a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -10205,9 +10504,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:update
|
||||
- factor-api-key:update
|
||||
- tokenizer:
|
||||
- serviceaccount:update
|
||||
- factor-api-key:update
|
||||
summary: Updates a service account key
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -10379,11 +10678,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
@@ -11976,6 +12275,81 @@ paths:
|
||||
summary: List Clusters for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/deployments:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes Deployments with key aggregated
|
||||
pod metrics: CPU usage and memory working set summed across pods owned by
|
||||
the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest,
|
||||
deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each
|
||||
row also reports the latest known desiredPods (k8s.deployment.desired) and
|
||||
availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase
|
||||
({ pending, running, succeeded, failed, unknown } from each pod''s latest
|
||||
k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name,
|
||||
k8s.namespace.name, k8s.cluster.name). The response type is ''list'' for the
|
||||
default k8s.deployment.name grouping or ''grouped_list'' for custom groupBy
|
||||
keys; in both modes every row aggregates pods owned by deployments in the
|
||||
group. Supports filtering via a filter expression, custom groupBy, ordering
|
||||
by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
|
||||
/ desired_pods / available_pods, 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 (deploymentCPU, deploymentCPURequest,
|
||||
deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit,
|
||||
desiredPods, availablePods) return -1 as a sentinel when no data is available
|
||||
for that field.'
|
||||
operationId: ListDeployments
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableDeployments'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesDeployments'
|
||||
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 Deployments for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/hosts:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -12044,6 +12418,84 @@ paths:
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/jobs:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes Jobs with key aggregated
|
||||
pod metrics: CPU usage and memory working set summed across pods owned by
|
||||
the job, plus average CPU/memory request and limit utilization (jobCPURequest,
|
||||
jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the
|
||||
latest known job-level counters from kube-state-metrics: desiredSuccessfulPods
|
||||
(k8s.job.desired_successful_pods, the target completion count), activePods
|
||||
(k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across
|
||||
the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative).
|
||||
It also reports per-group podCountsByPhase ({ pending, running, succeeded,
|
||||
failed, unknown } from each pod''s latest k8s.pod.phase value); note podCountsByPhase.failed
|
||||
(current pod-phase) is distinct from failedPods (cumulative job kube-state-metric).
|
||||
Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name).
|
||||
The response type is ''list'' for the default k8s.job.name grouping or ''grouped_list''
|
||||
for custom groupBy keys; in both modes every row aggregates pods owned by
|
||||
jobs in the group. Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit
|
||||
/ desired_successful_pods / active_pods / failed_pods / successful_pods, 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 (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest,
|
||||
jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods)
|
||||
return -1 as a sentinel when no data is available for that field.'
|
||||
operationId: ListJobs
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableJobs'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesJobs'
|
||||
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 Jobs for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/namespaces:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -12327,6 +12779,81 @@ paths:
|
||||
summary: List Volumes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/statefulsets:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes StatefulSets with key aggregated
|
||||
pod metrics: CPU usage and memory working set summed across pods owned by
|
||||
the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest,
|
||||
statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each
|
||||
row also reports the latest known desiredPods (k8s.statefulset.desired_pods)
|
||||
and currentPods (k8s.statefulset.current_pods) replica counts and per-group
|
||||
podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each
|
||||
pod''s latest k8s.pod.phase value). Each statefulset includes metadata attributes
|
||||
(k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response
|
||||
type is ''list'' for the default k8s.statefulset.name grouping or ''grouped_list''
|
||||
for custom groupBy keys; in both modes every row aggregates pods owned by
|
||||
statefulsets in the group. Supports filtering via a filter expression, custom
|
||||
groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request
|
||||
/ memory_limit / desired_pods / current_pods, 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 (statefulSetCPU,
|
||||
statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest,
|
||||
statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel
|
||||
when no data is available for that field.'
|
||||
operationId: ListStatefulSets
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableStatefulSets'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesStatefulSets'
|
||||
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 StatefulSets for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/livez:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -87,7 +87,7 @@ func (provider *provider) BatchCheck(ctx context.Context, tupleReq map[string]*o
|
||||
}
|
||||
|
||||
func (provider *provider) CheckTransactions(ctx context.Context, subject string, orgID valuer.UUID, transactions []*authtypes.Transaction) ([]*authtypes.TransactionWithAuthorization, error) {
|
||||
tuples, err := authtypes.NewTuplesFromTransactions(transactions, subject, orgID)
|
||||
tuples, correlations, err := authtypes.NewTuplesFromTransactionsWithCorrelations(transactions, subject, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -99,10 +99,21 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
|
||||
|
||||
results := make([]*authtypes.TransactionWithAuthorization, len(transactions))
|
||||
for i, txn := range transactions {
|
||||
result := batchResults[txn.ID.StringValue()]
|
||||
txnID := txn.ID.StringValue()
|
||||
authorized := batchResults[txnID].Authorized
|
||||
|
||||
if !authorized {
|
||||
for _, correlationID := range correlations[txnID] {
|
||||
if result, exists := batchResults[correlationID]; exists && result.Authorized {
|
||||
authorized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = &authtypes.TransactionWithAuthorization{
|
||||
Transaction: txn,
|
||||
Authorized: result.Authorized,
|
||||
Authorized: authorized,
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
|
||||
@@ -7,17 +7,27 @@ type organization
|
||||
|
||||
type user
|
||||
relations
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
define attach: [user, serviceaccount, role#assignee]
|
||||
define detach: [user, serviceaccount, role#assignee]
|
||||
|
||||
type serviceaccount
|
||||
relations
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
define attach: [user, serviceaccount, role#assignee]
|
||||
define detach: [user, serviceaccount, role#assignee]
|
||||
|
||||
type anonymous
|
||||
|
||||
@@ -25,25 +35,28 @@ type role
|
||||
relations
|
||||
define assignee: [user, serviceaccount, anonymous]
|
||||
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
define attach: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresources
|
||||
define attach: [user, serviceaccount, role#assignee]
|
||||
define detach: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresource
|
||||
relations
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresource
|
||||
relations
|
||||
define read: [user, serviceaccount, anonymous, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
define read: [user, serviceaccount, anonymous, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
define block: [user, serviceaccount, role#assignee]
|
||||
define block: [user, serviceaccount, role#assignee]
|
||||
|
||||
|
||||
type telemetryresource
|
||||
relations
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
@@ -114,7 +114,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
// initiate agent config handler
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, signoz.Modules.LLMPricingRule},
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
19
frontend/.cursor/rules/ui-components-and-icons.mdc
Normal file
19
frontend/.cursor/rules/ui-components-and-icons.mdc
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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,9 +5,15 @@ cd frontend && pnpm run commitlint --edit $1
|
||||
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
color_red="$(tput setaf 1)"
|
||||
bold="$(tput bold)"
|
||||
reset="$(tput sgr0)"
|
||||
if [ -n "$TERM" ] && [ "$TERM" != "dumb" ]; then
|
||||
color_red="$(tput setaf 1)"
|
||||
bold="$(tput bold)"
|
||||
reset="$(tput sgr0)"
|
||||
else
|
||||
color_red=""
|
||||
bold=""
|
||||
reset=""
|
||||
fi
|
||||
|
||||
if [ "$branch" = "main" ]; then
|
||||
echo "${color_red}${bold}You can't commit directly to the main branch${reset}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
registry = 'https://registry.npmjs.org/'
|
||||
engine-strict=true
|
||||
|
||||
public-hoist-pattern[]=@commitlint*
|
||||
public-hoist-pattern[]=commitlint
|
||||
@@ -291,6 +291,8 @@
|
||||
// Prevents window.open(path), window.location.origin + path, window.location.href = path
|
||||
"signoz/no-antd-components": "error",
|
||||
// Prevents the usage of specific antd components in favor of our lib
|
||||
"signoz/no-signozhq-ui-barrel": "error",
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
@@ -495,7 +497,8 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/api/generated/**/*.ts"
|
||||
"src/api/generated/**/*.ts",
|
||||
"src/api/ai-assistant/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
|
||||
@@ -29,6 +29,18 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
(window as any).IntersectionObserver = IntersectionObserverMock;
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"i18n:generate-hash": "node ./i18-generate-hash.cjs",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -26,7 +27,8 @@
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": ">=10.0.0 <11.0.0"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
@@ -51,7 +53,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.18",
|
||||
"@signozhq/ui": "0.0.19",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -135,6 +137,7 @@
|
||||
"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",
|
||||
@@ -173,18 +176,18 @@
|
||||
"@commitlint/config-conventional": "20.4.4",
|
||||
"@faker-js/faker": "9.3.0",
|
||||
"@jest/globals": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/fontfaceobserver": "2.1.0",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@jest/types": "30.2.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/mini-css-extract-plugin": "^2.5.1",
|
||||
"@types/node": "^16.10.3",
|
||||
|
||||
210
frontend/plugins/rules/no-signozhq-ui-barrel.mjs
Normal file
210
frontend/plugins/rules/no-signozhq-ui-barrel.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Rule: no-signozhq-ui-barrel
|
||||
*
|
||||
* Forbids importing from the `@signozhq/ui` barrel and requires the matching
|
||||
* subpath instead.
|
||||
*
|
||||
* This rule catches:
|
||||
* import { Typography } from '@signozhq/ui'
|
||||
* import { Button, toast } from '@signozhq/ui'
|
||||
* import '@signozhq/ui'
|
||||
*
|
||||
* And expects:
|
||||
* import { Typography } from '@signozhq/ui/typography'
|
||||
* import { Button } from '@signozhq/ui/button'
|
||||
* import { toast } from '@signozhq/ui/sonner'
|
||||
*
|
||||
* Why: the barrel eagerly require()s every component (~90 of them) along with
|
||||
* their Radix/cmdk/motion/react-day-picker dependencies. Under Jest this caused
|
||||
* 5s timeouts and flaky tests after the Antd→@signozhq/ui Typography migration
|
||||
* (#11199). Subpath imports (added in @signozhq/ui@0.0.18) load only what's
|
||||
* used.
|
||||
*
|
||||
* The auto-generated `auto-import-registry.d.ts` is a pure declaration file
|
||||
* that exists solely to nudge VS Code's auto-import indexer; its bare
|
||||
* `import '@signozhq/ui';` is type-only and not emitted, so it is exempt.
|
||||
*
|
||||
* Autofix:
|
||||
* Rewrites named imports to the matching subpath, splitting one statement
|
||||
* into multiple when specifiers come from different subpaths. The
|
||||
* export-name → subpath map is derived lazily from the installed
|
||||
* `@signozhq/ui` dist `.d.ts` files. Imports we can't classify (namespace,
|
||||
* default, side-effect, or unknown specifier) are reported without a fix.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ALLOWED_FILES = new Set(['auto-import-registry.d.ts']);
|
||||
|
||||
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let exportMap = null;
|
||||
|
||||
function loadExportMap() {
|
||||
if (exportMap === null) {
|
||||
exportMap = buildExportMap();
|
||||
}
|
||||
return exportMap;
|
||||
}
|
||||
|
||||
function buildExportMap() {
|
||||
const map = new Map();
|
||||
const root = findSignozUiRoot();
|
||||
if (!root) return map;
|
||||
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
} catch {
|
||||
return map;
|
||||
}
|
||||
|
||||
const subpathKeys = Object.keys(pkg.exports || {}).filter((k) => k !== '.');
|
||||
for (const key of subpathKeys) {
|
||||
const subpath = key.replace(/^\.\//, '');
|
||||
const entry = join(root, 'dist', subpath, 'index.d.ts');
|
||||
if (!existsSync(entry)) continue;
|
||||
|
||||
const names = new Set();
|
||||
collectExportedNames(entry, names, new Set());
|
||||
// First-wins: package.json subpath order is the canonical home for
|
||||
// names re-exported across multiple subpaths (e.g. `ToggleColor` is
|
||||
// declared in `toggle` and re-exported from `toggle-group`).
|
||||
for (const name of names) {
|
||||
if (!map.has(name)) map.set(name, subpath);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function findSignozUiRoot() {
|
||||
let dir = PLUGIN_DIR;
|
||||
while (true) {
|
||||
const candidate = join(dir, 'node_modules', '@signozhq', 'ui');
|
||||
if (existsSync(join(candidate, 'package.json'))) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function collectExportedNames(filepath, out, visited) {
|
||||
if (visited.has(filepath) || !existsSync(filepath)) return;
|
||||
visited.add(filepath);
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(filepath, 'utf-8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// `export * from './x.js'` / `export type * from './x.js'`
|
||||
for (const m of content.matchAll(
|
||||
/export\s+(?:type\s+)?\*\s+from\s+['"]([^'"]+)['"]/g,
|
||||
)) {
|
||||
collectExportedNames(resolveRelativeDts(filepath, m[1]), out, visited);
|
||||
}
|
||||
|
||||
// `export { Foo, type Bar, Foo as Baz } from '...';` and `export { ... };`
|
||||
for (const m of content.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
|
||||
for (const item of m[1].split(',')) {
|
||||
const cleaned = item.trim().replace(/^type\s+/, '');
|
||||
if (!cleaned) continue;
|
||||
const idMatch = cleaned.match(
|
||||
/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/,
|
||||
);
|
||||
if (idMatch) out.add(idMatch[2] || idMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// `export (declare) const|let|var|function|class|enum|type|interface Foo`
|
||||
for (const m of content.matchAll(
|
||||
/export\s+(?:declare\s+)?(?:const|let|var|function|class|enum|type|interface)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
|
||||
)) {
|
||||
out.add(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelativeDts(fromFile, spec) {
|
||||
const base = dirname(fromFile);
|
||||
const stripped = spec.replace(/\.(js|mjs|cjs)$/, '');
|
||||
const sibling = join(base, `${stripped}.d.ts`);
|
||||
if (existsSync(sibling)) return sibling;
|
||||
const indexed = join(base, stripped, 'index.d.ts');
|
||||
if (existsSync(indexed)) return indexed;
|
||||
return sibling;
|
||||
}
|
||||
|
||||
function buildReplacement(node, map) {
|
||||
const specifiers = node.specifiers || [];
|
||||
if (specifiers.length === 0) return null;
|
||||
|
||||
for (const spec of specifiers) {
|
||||
if (spec.type !== 'ImportSpecifier') return null;
|
||||
if (spec.imported?.type !== 'Identifier') return null;
|
||||
}
|
||||
|
||||
const quote = node.source.raw?.[0] === '"' ? '"' : "'";
|
||||
const topLevelType = node.importKind === 'type';
|
||||
const keyword = topLevelType ? 'import type' : 'import';
|
||||
|
||||
const groups = new Map();
|
||||
for (const spec of specifiers) {
|
||||
const importedName = spec.imported.name;
|
||||
const subpath = map.get(importedName);
|
||||
if (!subpath) return null;
|
||||
|
||||
const localName = spec.local.name;
|
||||
const inlineType = !topLevelType && spec.importKind === 'type';
|
||||
let text = inlineType ? 'type ' : '';
|
||||
text += importedName;
|
||||
if (localName !== importedName) text += ` as ${localName}`;
|
||||
|
||||
if (!groups.has(subpath)) groups.set(subpath, []);
|
||||
groups.get(subpath).push(text);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
for (const [subpath, items] of groups) {
|
||||
lines.push(
|
||||
`${keyword} { ${items.join(', ')} } from ${quote}@signozhq/ui/${subpath}${quote};`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
fixable: 'code',
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename || '';
|
||||
const basename = filename.split(/[\\/]/).pop();
|
||||
if (ALLOWED_FILES.has(basename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value !== '@signozhq/ui') {
|
||||
return;
|
||||
}
|
||||
|
||||
const replacement = buildReplacement(node, loadExportMap());
|
||||
const report = {
|
||||
node: node.source,
|
||||
message:
|
||||
"Do not import from the '@signozhq/ui' barrel. Use the matching subpath instead (e.g. '@signozhq/ui/typography', '@signozhq/ui/button', '@signozhq/ui/sonner'). The barrel eagerly loads ~90 components and slows tests substantially.",
|
||||
};
|
||||
if (replacement) {
|
||||
report.fix = (fixer) => fixer.replaceText(node, replacement);
|
||||
}
|
||||
context.report(report);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import noNavigatorClipboard from './rules/no-navigator-clipboard.mjs';
|
||||
import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs';
|
||||
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -21,5 +22,6 @@ export default {
|
||||
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
},
|
||||
};
|
||||
|
||||
13
frontend/pnpm-lock.yaml
generated
13
frontend/pnpm-lock.yaml
generated
@@ -89,8 +89,8 @@ importers:
|
||||
specifier: 0.0.2
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.18
|
||||
version: 0.0.18(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)
|
||||
specifier: 0.0.19
|
||||
version: 0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -340,6 +340,9 @@ importers:
|
||||
rehype-raw:
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0
|
||||
remark-gfm:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
rollup-plugin-visualizer:
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0(rolldown@1.0.0-beta.53)
|
||||
@@ -3683,8 +3686,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
|
||||
'@signozhq/ui@0.0.18':
|
||||
resolution: {integrity: sha512-1p3ALh76kafiz5yX7ReNKVcHDt2od7CcZD/Vx9i2adTwTeynkLJcEfVoXoJD3oh1kKTleooOiOjRyxlA7VzmSA==}
|
||||
'@signozhq/ui@0.0.19':
|
||||
resolution: {integrity: sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==}
|
||||
peerDependencies:
|
||||
'@signozhq/icons': 0.3.0
|
||||
react: ^18.2.0
|
||||
@@ -13952,7 +13955,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.18(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.27.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
|
||||
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
|
||||
"your_graph_build_with": "Your graph built with",
|
||||
"dashboard_ok_confirm": "query will be saved. Press OK to confirm."
|
||||
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,6 @@
|
||||
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
|
||||
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
|
||||
"your_graph_build_with": "Your graph built with",
|
||||
"dashboard_ok_confirm": "query will be saved. Press OK to confirm."
|
||||
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTE
|
||||
missing_languages=()
|
||||
|
||||
for lang in $md_languages; do
|
||||
# Skip ai-* block markers — these are custom AI block types rendered by
|
||||
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
|
||||
# not real syntax languages, so they don't need highlighter registration.
|
||||
if [[ "$lang" == ai-* ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! echo "$registered_languages" | grep -qx "$lang"; then
|
||||
missing_languages+=("$lang")
|
||||
fi
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
@@ -40,6 +41,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
} = useAppContext();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -99,6 +102,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
|
||||
@@ -164,14 +164,17 @@ function createMockAppContext(
|
||||
featureFlags: [],
|
||||
orgPreferences: createMockOrgPreferences(),
|
||||
userPreferences: [],
|
||||
hostsData: null,
|
||||
isLoggedIn: true,
|
||||
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
userFetchError: null,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: null,
|
||||
featureFlagsFetchError: null,
|
||||
orgPreferencesFetchError: null,
|
||||
changelog: null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { NotificationProvider } from 'hooks/useNotifications';
|
||||
@@ -60,13 +61,21 @@ function App(): JSX.Element {
|
||||
org,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { hostname, pathname } = window.location;
|
||||
const { hostname } = window.location;
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return history.listen((location) => {
|
||||
setPathname(location.pathname);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const enableAnalytics = useCallback(
|
||||
(user: IUser): void => {
|
||||
// wait for the required data to be loaded before doing init for anything!
|
||||
@@ -212,6 +221,27 @@ function App(): JSX.Element {
|
||||
activeLicenseFetchError,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedInState) {
|
||||
return;
|
||||
}
|
||||
|
||||
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, isAIAssistantEnabled]);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -221,7 +251,8 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
} else {
|
||||
|
||||
@@ -324,3 +324,10 @@ export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
export const AIAssistantPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
@@ -507,6 +508,13 @@ 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 = {
|
||||
|
||||
80
frontend/src/api/AIAPIInstance.ts
Normal file
80
frontend/src/api/AIAPIInstance.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
import {
|
||||
interceptorRejected,
|
||||
interceptorsRequestBasePath,
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
|
||||
|
||||
/** Path-only base for the AI Assistant API. */
|
||||
export const AI_API_PATH = '/api/v1/assistant';
|
||||
|
||||
/** Header that tells the AI backend which SigNoz instance to query against. */
|
||||
export const SIGNOZ_URL_HEADER = 'X-SigNoz-URL';
|
||||
|
||||
/**
|
||||
* Sets `X-SigNoz-URL` on every outgoing AI Assistant request. The backend
|
||||
* needs the originating SigNoz instance URL for multi-tenant deployments;
|
||||
* when omitted it falls back to its `SIGNOZ_API_URL` env var.
|
||||
*/
|
||||
export const interceptorsRequestSigNozUrl = (
|
||||
value: InternalAxiosRequestConfig,
|
||||
): InternalAxiosRequestConfig => {
|
||||
if (value.headers) {
|
||||
value.headers[SIGNOZ_URL_HEADER] = getSigNozInstanceUrl();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* AI backend URL — sourced from the global config's `ai_assistant_url` field
|
||||
* at runtime. `useIsAIAssistantEnabled` keeps this in sync via `setAIBackendUrl`
|
||||
* whenever the config response changes; consumers (the axios instance and the
|
||||
* SSE fetch path) read it lazily so they always see the current value.
|
||||
*/
|
||||
let aiBackendUrl: string | null = null;
|
||||
|
||||
export function setAIBackendUrl(url: string | null): void {
|
||||
if (aiBackendUrl === url) {
|
||||
return;
|
||||
}
|
||||
aiBackendUrl = url;
|
||||
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Full base URL for the AI Assistant API (host + path). Throws when the
|
||||
* config hasn't yet provided a URL — should never happen in practice
|
||||
* because `useIsAIAssistantEnabled` gates every consumer surface.
|
||||
*/
|
||||
export function getAIBaseUrl(): string {
|
||||
if (!aiBackendUrl) {
|
||||
throw new Error('AI assistant URL is not configured.');
|
||||
}
|
||||
return `${aiBackendUrl}${AI_API_PATH}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated axios instance for the AI Assistant.
|
||||
*
|
||||
* Mirrors the request/response interceptor stack of the main SigNoz axios
|
||||
* instance — most importantly `interceptorRejected`, which transparently
|
||||
* rotates the access token via `/sessions/rotate` on a 401 and replays the
|
||||
* original request. That's why we don't need any AI-specific 401 handling
|
||||
* for REST calls: this instance inherits the same flow as the rest of the
|
||||
* app for free.
|
||||
*
|
||||
* Only the SSE stream (`streamEvents`) still needs raw fetch since axios
|
||||
* doesn't expose `ReadableStream` — that path keeps its own auth wrapper.
|
||||
*/
|
||||
export const AIAssistantInstance = axios.create({});
|
||||
|
||||
AIAssistantInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
AIAssistantInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
AIAssistantInstance.interceptors.request.use(interceptorsRequestSigNozUrl);
|
||||
AIAssistantInstance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
interceptorRejected,
|
||||
);
|
||||
543
frontend/src/api/ai-assistant/chat.ts
Normal file
543
frontend/src/api/ai-assistant/chat.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Types in this file re-use the OpenAPI-generated DTOs in
|
||||
* `src/api/ai-assistant/sigNozAIAssistantAPI.schemas.ts`.
|
||||
* Local types are defined only when the UI needs a different shape — for
|
||||
* example, the SSE event union adds a literal `type` discriminator that the
|
||||
* generated event DTOs leave loose.
|
||||
*
|
||||
* REST calls go through `AIAssistantInstance` (an axios instance configured
|
||||
* with the same interceptor stack as the rest of the app) — that gives them
|
||||
* automatic 401-then-rotate behaviour for free. Only the SSE call is still
|
||||
* a raw `fetch` because axios doesn't expose `ReadableStream`; that one
|
||||
* path gets its own small auth wrapper.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import { Logout } from 'api/utils';
|
||||
import rotateSession from 'api/v2/sessions/rotate/post';
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
import type {
|
||||
ActionResultResponseDTO,
|
||||
ApprovalEventDTO,
|
||||
ApproveResponseDTO,
|
||||
CancelResponseDTO,
|
||||
ClarificationEventDTO,
|
||||
ClarifyResponseDTO,
|
||||
ConversationEventDTO,
|
||||
CreateMessageResponseDTO,
|
||||
CreateThreadResponseDTO,
|
||||
DoneEventDTO,
|
||||
ErrorEventDTO,
|
||||
ExecutionStateDTO,
|
||||
FeedbackRatingDTO,
|
||||
ListThreadsApiV1AssistantThreadsGetArchived,
|
||||
ListThreadsApiV1AssistantThreadsGetParams,
|
||||
MessageContextDTO,
|
||||
MessageContextDTOSource,
|
||||
MessageContextDTOType,
|
||||
MessageEventDTO,
|
||||
MessageSummaryDTO,
|
||||
RegenerateResponseDTO,
|
||||
StatusEventDTO,
|
||||
ThinkingEventDTO,
|
||||
ThreadDetailResponseDTO,
|
||||
ThreadListResponseDTO,
|
||||
ThreadSummaryDTO,
|
||||
ToolCallEventDTO,
|
||||
ToolResultEventDTO,
|
||||
} from './sigNozAIAssistantAPI.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import {
|
||||
AIAssistantInstance,
|
||||
getAIBaseUrl,
|
||||
SIGNOZ_URL_HEADER,
|
||||
} from '../AIAPIInstance';
|
||||
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE-only auth wrapper.
|
||||
//
|
||||
// REST calls go through `AIAssistantInstance` (axios) and get refresh-token
|
||||
// behaviour from the shared `interceptorRejected`. The SSE call has to use
|
||||
// raw `fetch` (axios can't stream a `ReadableStream`), so it can't ride that
|
||||
// interceptor — this small wrapper handles 401 at SSE open time by hitting
|
||||
// the same rotate endpoint and replaying the request once.
|
||||
//
|
||||
// In typical use a REST call (e.g. sendMessage / loadThread) precedes every
|
||||
// stream open, so axios will already have refreshed the token and `fetch`
|
||||
// just reads the fresh one from localStorage. The wrapper exists for the
|
||||
// edge case where SSE is the first call to encounter a 401.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let pendingRotate: Promise<string | null> | null = null;
|
||||
|
||||
async function rotateAccessToken(): Promise<string | null> {
|
||||
if (pendingRotate) {
|
||||
return pendingRotate;
|
||||
}
|
||||
const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '';
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
pendingRotate = (async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await rotateSession({ refreshToken });
|
||||
afterLogin(response.data.accessToken, response.data.refreshToken, true);
|
||||
return response.data.accessToken;
|
||||
} catch {
|
||||
Logout();
|
||||
return null;
|
||||
} finally {
|
||||
pendingRotate = null;
|
||||
}
|
||||
})();
|
||||
return pendingRotate;
|
||||
}
|
||||
|
||||
// Backoff schedule for 429 retries on SSE open. Three attempts is enough to
|
||||
// absorb the brief window between cancel→send→stream when the backend is
|
||||
// rate-limiting the burst, without making real "you're saturated" errors
|
||||
// take forever to surface.
|
||||
const SSE_429_BACKOFF_MS = [400, 1200, 2500];
|
||||
|
||||
function parseRetryAfterMs(value: string | null): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) {
|
||||
return Math.max(0, seconds * 1000);
|
||||
}
|
||||
const date = Date.parse(value);
|
||||
if (Number.isFinite(date)) {
|
||||
return Math.max(0, date - Date.now());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchSSEWithAuth(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const send = async (token: string | null): Promise<Response> => {
|
||||
const headers: Record<string, string> = {
|
||||
[SIGNOZ_URL_HEADER]: getSigNozInstanceUrl(),
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return fetch(url, { headers, signal });
|
||||
};
|
||||
|
||||
const sendWithAuth = async (): Promise<Response> => {
|
||||
const initialToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
|
||||
const res = await send(initialToken);
|
||||
if (res.status !== 401) {
|
||||
return res;
|
||||
}
|
||||
const refreshed = await rotateAccessToken();
|
||||
if (!refreshed) {
|
||||
return res;
|
||||
}
|
||||
return send(refreshed);
|
||||
};
|
||||
|
||||
let res = await sendWithAuth();
|
||||
for (const baseDelay of SSE_429_BACKOFF_MS) {
|
||||
if (res.status !== 429 || signal?.aborted) {
|
||||
return res;
|
||||
}
|
||||
const retryAfter = parseRetryAfterMs(res.headers.get('Retry-After'));
|
||||
const delay = retryAfter ?? baseDelay;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delay);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('SSE 429 backoff aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
res = await sendWithAuth();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE event types
|
||||
//
|
||||
// The generated event DTOs each declare `type?: string` (loose). The UI needs
|
||||
// a discriminated union, so we intersect each variant with a string-literal
|
||||
// `type` to enable narrowing via `event.type === 'status'`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SSEEvent =
|
||||
| (StatusEventDTO & { type: 'status' })
|
||||
| (MessageEventDTO & { type: 'message' })
|
||||
| (ThinkingEventDTO & { type: 'thinking' })
|
||||
| (ToolCallEventDTO & { type: 'tool_call' })
|
||||
| (ToolResultEventDTO & { type: 'tool_result' })
|
||||
| (ApprovalEventDTO & { type: 'approval' })
|
||||
| (ClarificationEventDTO & { type: 'clarification' })
|
||||
| (ErrorEventDTO & { type: 'error' })
|
||||
| (ConversationEventDTO & { type: 'conversation' })
|
||||
| (DoneEventDTO & { type: 'done' });
|
||||
|
||||
/** String-literal view of `ExecutionStateDTO` for ergonomic comparisons. */
|
||||
export type ExecutionState = `${ExecutionStateDTO}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exported DTOs — the wire shape, used directly without remapping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ThreadSummary = ThreadSummaryDTO;
|
||||
export type ThreadListResponse = ThreadListResponseDTO;
|
||||
export type ThreadDetailResponse = ThreadDetailResponseDTO;
|
||||
export type MessageSummary = MessageSummaryDTO;
|
||||
export type CancelResponse = CancelResponseDTO;
|
||||
|
||||
/**
|
||||
* Construction-friendly view of `MessageContextDTO`: enum fields are widened
|
||||
* to their string-literal unions so call-sites can pass `'mention'` instead
|
||||
* of `MessageContextDTOSource.mention`.
|
||||
*/
|
||||
export type MessageContext = Omit<MessageContextDTO, 'source' | 'type'> & {
|
||||
source: `${MessageContextDTOSource}`;
|
||||
type: `${MessageContextDTOType}`;
|
||||
};
|
||||
|
||||
/** Construction-friendly view of `ListThreadsApiV1AssistantThreadsGetParams`. */
|
||||
export type ListThreadsOptions = Omit<
|
||||
ListThreadsApiV1AssistantThreadsGetParams,
|
||||
'archived'
|
||||
> & {
|
||||
archived?: `${ListThreadsApiV1AssistantThreadsGetArchived}`;
|
||||
};
|
||||
|
||||
/** String-literal view of `FeedbackRatingDTO` so call-sites can pass `'positive'`/`'negative'`. */
|
||||
export type FeedbackRating = `${FeedbackRatingDTO}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread listing & detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listThreads(
|
||||
options: ListThreadsOptions = {},
|
||||
): Promise<ThreadListResponse> {
|
||||
const {
|
||||
archived = 'false',
|
||||
limit = 20,
|
||||
cursor = null,
|
||||
sort = 'updated_desc',
|
||||
} = options;
|
||||
const response = await AIAssistantInstance.get<ThreadListResponse>(
|
||||
'/threads',
|
||||
{
|
||||
params: {
|
||||
archived,
|
||||
limit,
|
||||
sort,
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateThread(
|
||||
threadId: string,
|
||||
update: { title?: string | null; archived?: boolean | null },
|
||||
): Promise<ThreadSummary> {
|
||||
const response = await AIAssistantInstance.patch<ThreadSummary>(
|
||||
`/threads/${threadId}`,
|
||||
update,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getThreadDetail(
|
||||
threadId: string,
|
||||
): Promise<ThreadDetailResponse> {
|
||||
const response = await AIAssistantInstance.get<ThreadDetailResponse>(
|
||||
`/threads/${threadId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — Create thread
|
||||
// POST /api/v1/assistant/threads → { threadId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createThread(signal?: AbortSignal): Promise<string> {
|
||||
const response = await AIAssistantInstance.post<CreateThreadResponseDTO>(
|
||||
'/threads',
|
||||
{},
|
||||
{ signal },
|
||||
);
|
||||
return response.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> {
|
||||
try {
|
||||
const response = await AIAssistantInstance.get<ThreadDetailResponseDTO>(
|
||||
`/threads/${threadId}`,
|
||||
);
|
||||
return response.data.activeExecutionId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
contexts?: MessageContext[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await AIAssistantInstance.post<CreateMessageResponseDTO>(
|
||||
`/threads/${threadId}/messages`,
|
||||
{
|
||||
content,
|
||||
...(contexts && contexts.length > 0 ? { contexts } : {}),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return response.data.executionId;
|
||||
} catch (err) {
|
||||
// Thread already has an active execution — reconnect to it instead of
|
||||
// failing the user's send.
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
const executionId = await getActiveExecutionId(threadId);
|
||||
if (executionId) {
|
||||
return executionId;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `streamEvents` when the SSE open returns a non-2xx response.
|
||||
* Carries the HTTP status so callers can branch on rate-limit vs. other
|
||||
* failures (e.g. show a "please wait a moment" message on 429).
|
||||
*/
|
||||
export class SSEStreamError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, statusText: string) {
|
||||
super(`SSE stream failed: ${status} ${statusText}`);
|
||||
this.name = 'SSEStreamError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamEvents(
|
||||
executionId: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const res = await fetchSSEWithAuth(
|
||||
`${getAIBaseUrl()}/executions/${executionId}/events`,
|
||||
signal,
|
||||
);
|
||||
if (!res.ok || !res.body) {
|
||||
throw new SSEStreamError(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 response = await AIAssistantInstance.post<ApproveResponseDTO>(
|
||||
'/approve',
|
||||
{ approvalId },
|
||||
{ signal },
|
||||
);
|
||||
return response.data.executionId;
|
||||
}
|
||||
|
||||
/** Reject a pending action. */
|
||||
export async function rejectExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await AIAssistantInstance.post('/reject', { approvalId }, { signal });
|
||||
}
|
||||
|
||||
/** 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 response = await AIAssistantInstance.post<ClarifyResponseDTO>(
|
||||
'/clarify',
|
||||
{ clarificationId, answers },
|
||||
{ signal },
|
||||
);
|
||||
return response.data.executionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean-slate regeneration of an assistant response. The backend rewinds the
|
||||
* conversation up to (excluding) the supplied messageId and starts a fresh
|
||||
* execution. Returns the new executionId — open an SSE stream for it the
|
||||
* same way `sendMessage` and `approve` do.
|
||||
*/
|
||||
export async function regenerateMessage(
|
||||
messageId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const response = await AIAssistantInstance.post<RegenerateResponseDTO>(
|
||||
`/messages/${messageId}/regenerate`,
|
||||
undefined,
|
||||
{ signal },
|
||||
);
|
||||
return response.data.executionId;
|
||||
}
|
||||
|
||||
export async function cancelExecution(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CancelResponse> {
|
||||
const response = await AIAssistantInstance.post<CancelResponse>(
|
||||
'/cancel',
|
||||
{ threadId },
|
||||
{ signal },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rollback actions — undo / revert / restore
|
||||
// All three POST `{ actionMetadataId }` and return `ActionResultResponseDTO`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function postRollback(
|
||||
endpoint: 'undo' | 'revert' | 'restore',
|
||||
actionMetadataId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ActionResultResponseDTO> {
|
||||
const response = await AIAssistantInstance.post<ActionResultResponseDTO>(
|
||||
`/${endpoint}`,
|
||||
{ actionMetadataId },
|
||||
{ signal },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const undoExecution = (
|
||||
actionMetadataId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ActionResultResponseDTO> =>
|
||||
postRollback('undo', actionMetadataId, signal);
|
||||
|
||||
export const revertExecution = (
|
||||
actionMetadataId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ActionResultResponseDTO> =>
|
||||
postRollback('revert', actionMetadataId, signal);
|
||||
|
||||
export const restoreExecution = (
|
||||
actionMetadataId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ActionResultResponseDTO> =>
|
||||
postRollback('restore', actionMetadataId, signal);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function submitFeedback(
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
comment?: string,
|
||||
): Promise<void> {
|
||||
await AIAssistantInstance.post(`/messages/${messageId}/feedback`, {
|
||||
rating,
|
||||
comment: comment ?? null,
|
||||
});
|
||||
}
|
||||
1820
frontend/src/api/ai-assistant/index.ts
Normal file
1820
frontend/src/api/ai-assistant/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
1317
frontend/src/api/ai-assistant/sigNozAIAssistantAPI.schemas.ts
Normal file
1317
frontend/src/api/ai-assistant/sigNozAIAssistantAPI.schemas.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,16 +13,22 @@ import type {
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostableJobsDTO,
|
||||
InframonitoringtypesPostableNamespacesDTO,
|
||||
InframonitoringtypesPostableNodesDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
InframonitoringtypesPostableStatefulSetsDTO,
|
||||
InframonitoringtypesPostableVolumesDTO,
|
||||
ListClusters200,
|
||||
ListDeployments200,
|
||||
ListHosts200,
|
||||
ListJobs200,
|
||||
ListNamespaces200,
|
||||
ListNodes200,
|
||||
ListPods200,
|
||||
ListStatefulSets200,
|
||||
ListVolumes200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -114,6 +120,90 @@ export const useListClusters = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, 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 (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Deployments for Infra Monitoring
|
||||
*/
|
||||
export const listDeployments = (
|
||||
inframonitoringtypesPostableDeploymentsDTO: BodyType<InframonitoringtypesPostableDeploymentsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListDeployments200>({
|
||||
url: `/api/v2/infra_monitoring/deployments`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableDeploymentsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListDeploymentsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listDeployments>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listDeployments>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listDeployments'];
|
||||
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 listDeployments>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listDeployments(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListDeploymentsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listDeployments>>
|
||||
>;
|
||||
export type ListDeploymentsMutationBody =
|
||||
BodyType<InframonitoringtypesPostableDeploymentsDTO>;
|
||||
export type ListDeploymentsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Deployments for Infra Monitoring
|
||||
*/
|
||||
export const useListDeployments = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listDeployments>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listDeployments>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListDeploymentsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* 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
|
||||
@@ -198,6 +288,90 @@ export const useListHosts = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes Jobs with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the job, plus average CPU/memory request and limit utilization (jobCPURequest, jobCPULimit, jobMemoryRequest, jobMemoryLimit). Each row also reports the latest known job-level counters from kube-state-metrics: desiredSuccessfulPods (k8s.job.desired_successful_pods, the target completion count), activePods (k8s.job.active_pods), failedPods (k8s.job.failed_pods, cumulative across the lifetime of the job), and successfulPods (k8s.job.successful_pods, cumulative). It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value); note podCountsByPhase.failed (current pod-phase) is distinct from failedPods (cumulative job kube-state-metric). Each job includes metadata attributes (k8s.job.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.job.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by jobs in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_successful_pods / active_pods / failed_pods / successful_pods, 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 (jobCPU, jobCPURequest, jobCPULimit, jobMemory, jobMemoryRequest, jobMemoryLimit, desiredSuccessfulPods, activePods, failedPods, successfulPods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Jobs for Infra Monitoring
|
||||
*/
|
||||
export const listJobs = (
|
||||
inframonitoringtypesPostableJobsDTO: BodyType<InframonitoringtypesPostableJobsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListJobs200>({
|
||||
url: `/api/v2/infra_monitoring/jobs`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableJobsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListJobsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listJobs>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listJobs>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listJobs'];
|
||||
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 listJobs>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableJobsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listJobs(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListJobsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listJobs>>
|
||||
>;
|
||||
export type ListJobsMutationBody =
|
||||
BodyType<InframonitoringtypesPostableJobsDTO>;
|
||||
export type ListJobsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Jobs for Infra Monitoring
|
||||
*/
|
||||
export const useListJobs = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listJobs>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listJobs>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableJobsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListJobsMutationOptions(options);
|
||||
|
||||
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 podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). 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
|
||||
@@ -534,3 +708,87 @@ export const useListVolumes = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes StatefulSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the statefulset, plus average CPU/memory request and limit utilization (statefulSetCPURequest, statefulSetCPULimit, statefulSetMemoryRequest, statefulSetMemoryLimit). Each row also reports the latest known desiredPods (k8s.statefulset.desired_pods) and currentPods (k8s.statefulset.current_pods) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each statefulset includes metadata attributes (k8s.statefulset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.statefulset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by statefulsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / current_pods, 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 (statefulSetCPU, statefulSetCPURequest, statefulSetCPULimit, statefulSetMemory, statefulSetMemoryRequest, statefulSetMemoryLimit, desiredPods, currentPods) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List StatefulSets for Infra Monitoring
|
||||
*/
|
||||
export const listStatefulSets = (
|
||||
inframonitoringtypesPostableStatefulSetsDTO: BodyType<InframonitoringtypesPostableStatefulSetsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListStatefulSets200>({
|
||||
url: `/api/v2/infra_monitoring/statefulsets`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableStatefulSetsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListStatefulSetsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listStatefulSets>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableStatefulSetsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listStatefulSets>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableStatefulSetsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listStatefulSets'];
|
||||
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 listStatefulSets>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableStatefulSetsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listStatefulSets(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListStatefulSetsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listStatefulSets>>
|
||||
>;
|
||||
export type ListStatefulSetsMutationBody =
|
||||
BodyType<InframonitoringtypesPostableStatefulSetsDTO>;
|
||||
export type ListStatefulSetsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List StatefulSets for Infra Monitoring
|
||||
*/
|
||||
export const useListStatefulSets = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listStatefulSets>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableStatefulSetsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listStatefulSets>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableStatefulSetsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListStatefulSetsMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
|
||||
@@ -1840,6 +1840,7 @@ export enum AuthtypesRelationDTO {
|
||||
list = 'list',
|
||||
assignee = 'assignee',
|
||||
attach = 'attach',
|
||||
detach = 'detach',
|
||||
}
|
||||
export interface AuthtypesRoleDTO {
|
||||
/**
|
||||
@@ -4161,7 +4162,7 @@ export enum CoretypesTypeDTO {
|
||||
role = 'role',
|
||||
organization = 'organization',
|
||||
metaresource = 'metaresource',
|
||||
metaresources = 'metaresources',
|
||||
telemetryresource = 'telemetryresource',
|
||||
}
|
||||
export interface DashboardtypesDashboardDTO {
|
||||
/**
|
||||
@@ -4628,6 +4629,83 @@ export interface InframonitoringtypesClustersDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesDeploymentRecordDTOMeta = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesDeploymentRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
availablePods: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
deploymentCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
deploymentCPULimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
deploymentCPURequest: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
deploymentMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
deploymentMemoryLimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
deploymentMemoryRequest: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
desiredPods: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesDeploymentRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDeploymentsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesDeploymentRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesHostFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4713,6 +4791,91 @@ export interface InframonitoringtypesHostsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesJobRecordDTOMeta = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesJobRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
activePods: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
desiredSuccessfulPods: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
failedPods: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
jobCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
jobCPULimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
jobCPURequest: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
jobMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
jobMemoryLimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
jobMemoryRequest: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
jobName: string;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesJobRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
successfulPods: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesJobsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesJobRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -4973,6 +5136,34 @@ export interface InframonitoringtypesPostableClustersDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableDeploymentsDTO {
|
||||
/**
|
||||
* @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 InframonitoringtypesPostableHostsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -5001,6 +5192,34 @@ export interface InframonitoringtypesPostableHostsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableJobsDTO {
|
||||
/**
|
||||
* @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 InframonitoringtypesPostableNamespacesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -5085,6 +5304,34 @@ export interface InframonitoringtypesPostablePodsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableStatefulSetsDTO {
|
||||
/**
|
||||
* @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 InframonitoringtypesPostableVolumesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -5125,6 +5372,83 @@ export enum InframonitoringtypesResponseTypeDTO {
|
||||
list = 'list',
|
||||
grouped_list = 'grouped_list',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesStatefulSetRecordDTOMeta = {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesStatefulSetRecordDTO {
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
currentPods: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
desiredPods: number;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
statefulSetCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
statefulSetCPULimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
statefulSetCPURequest: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
statefulSetMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
statefulSetMemoryLimit: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
statefulSetMemoryRequest: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
statefulSetName: string;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesStatefulSetsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesStatefulSetRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -9509,6 +9833,14 @@ export type ListClusters200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListDeployments200 = {
|
||||
data: InframonitoringtypesDeploymentsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListHosts200 = {
|
||||
data: InframonitoringtypesHostsDTO;
|
||||
/**
|
||||
@@ -9517,6 +9849,14 @@ export type ListHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListJobs200 = {
|
||||
data: InframonitoringtypesJobsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNamespaces200 = {
|
||||
data: InframonitoringtypesNamespacesDTO;
|
||||
/**
|
||||
@@ -9549,6 +9889,14 @@ export type ListVolumes200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListStatefulSets200 = {
|
||||
data: InframonitoringtypesStatefulSetsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Livez200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,46 @@ import {
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
// generated API Instance
|
||||
const generatedAPIAxiosInstance = axios.create({
|
||||
baseURL: ENVIRONMENT.baseURL,
|
||||
});
|
||||
|
||||
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
|
||||
|
||||
export const setGeneratedAPIQueryKeyHeaderContext = <THeaders extends object>(
|
||||
headers?: THeaders,
|
||||
): void => {
|
||||
generatedAPIQueryKeyHeaderContext = headers
|
||||
? { ...(headers as Record<string, unknown>) }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const hashHeaderValue = (value: string): string => {
|
||||
let hash = 0;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
|
||||
return hash.toString(16);
|
||||
};
|
||||
|
||||
const mergeHeaderRecord = (
|
||||
target: Record<string, unknown>,
|
||||
source: unknown,
|
||||
): Record<string, unknown> => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return target;
|
||||
}
|
||||
|
||||
return Object.assign(target, source as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const GeneratedAPIInstance = <T>(
|
||||
config: AxiosRequestConfig,
|
||||
): Promise<T> => {
|
||||
@@ -26,5 +58,59 @@ generatedAPIAxiosInstance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
|
||||
const getDefaultQueryKeyHeaders = (): Record<string, unknown> => {
|
||||
const defaults = generatedAPIAxiosInstance.defaults
|
||||
.headers as unknown as Record<string, unknown>;
|
||||
const headers: Record<string, unknown> = {};
|
||||
const methodKeys = new Set([
|
||||
'common',
|
||||
'delete',
|
||||
'get',
|
||||
'head',
|
||||
'options',
|
||||
'patch',
|
||||
'post',
|
||||
'put',
|
||||
]);
|
||||
|
||||
mergeHeaderRecord(headers, defaults?.common);
|
||||
mergeHeaderRecord(headers, defaults?.get);
|
||||
|
||||
for (const [key, value] of Object.entries(defaults ?? {})) {
|
||||
if (!methodKeys.has(key)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const getGeneratedAPIQueryKeyHeaders = <THeaders extends object>(
|
||||
headers?: THeaders,
|
||||
): [{ headers: Record<string, unknown> }] | [] => {
|
||||
const mergedHeaders = {
|
||||
...getDefaultQueryKeyHeaders(),
|
||||
...generatedAPIQueryKeyHeaderContext,
|
||||
...(headers as Record<string, unknown> | undefined),
|
||||
};
|
||||
|
||||
const queryKeyHeaders = Object.fromEntries(
|
||||
Object.entries(mergedHeaders)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => {
|
||||
if (key.toLowerCase() === 'authorization' && typeof value === 'string') {
|
||||
return [key, hashHeaderValue(value)];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.keys(queryKeyHeaders).length
|
||||
? [{ headers: queryKeyHeaders }]
|
||||
: [];
|
||||
};
|
||||
|
||||
export type ErrorType<Error> = AxiosError<Error>;
|
||||
export type BodyType<BodyData> = BodyData;
|
||||
|
||||
@@ -40,6 +40,7 @@ const getTraceV3 = async (
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
|
||||
@@ -437,11 +437,16 @@ export function convertTraceOperatorToV5(
|
||||
panelType,
|
||||
);
|
||||
|
||||
// Skip aggregation for raw request type
|
||||
// Skip aggregation for raw request type. Force dataSource to traces so
|
||||
// createAggregation never takes the metrics branch (which would emit a
|
||||
// metricName field the backend rejects for trace operators).
|
||||
const aggregations =
|
||||
requestType === 'raw'
|
||||
? undefined
|
||||
: createAggregation(traceOperatorData, panelType);
|
||||
: createAggregation(
|
||||
{ ...traceOperatorData, dataSource: DataSource.TRACES },
|
||||
panelType,
|
||||
);
|
||||
|
||||
const spec: QueryEnvelope['spec'] = {
|
||||
name: queryName,
|
||||
|
||||
@@ -596,6 +596,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',
|
||||
|
||||
@@ -103,7 +103,7 @@ function EditMemberDrawer({
|
||||
const { user: currentUser } = useAppContext();
|
||||
|
||||
const [localDisplayName, setLocalDisplayName] = useState('');
|
||||
const [localRole, setLocalRole] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -141,7 +141,7 @@ function EditMemberDrawer({
|
||||
} = useRoles();
|
||||
|
||||
const {
|
||||
fetchedRoleIds,
|
||||
currentRoles: currentMemberRoles,
|
||||
isLoading: isMemberRolesLoading,
|
||||
applyDiff,
|
||||
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
|
||||
@@ -188,16 +188,24 @@ function EditMemberDrawer({
|
||||
if (!member?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
setLocalRoles(
|
||||
currentMemberRoles.map((r) => r.id).filter(Boolean) as string[],
|
||||
);
|
||||
roleSessionRef.current = member.id;
|
||||
}
|
||||
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
|
||||
}, [member?.id, currentMemberRoles, isMemberRolesLoading]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
fetchedUser != null &&
|
||||
(localDisplayName !== fetchedDisplayName ||
|
||||
localRole !== (fetchedRoleIds[0] ?? ''));
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify(
|
||||
currentMemberRoles
|
||||
.map((r) => r.id)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
));
|
||||
|
||||
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
@@ -272,7 +280,14 @@ function EditMemberDrawer({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const nameChanged = localDisplayName !== fetchedDisplayName;
|
||||
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
|
||||
const rolesChanged =
|
||||
JSON.stringify([...localRoles].sort()) !==
|
||||
JSON.stringify(
|
||||
currentMemberRoles
|
||||
.map((r) => r.id)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
);
|
||||
|
||||
const namePromise = nameChanged
|
||||
? isSelf
|
||||
@@ -286,7 +301,7 @@ function EditMemberDrawer({
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
rolesChanged
|
||||
? applyDiff([localRole].filter(Boolean), availableRoles)
|
||||
? applyDiff([...localRoles], availableRoles)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
@@ -305,10 +320,7 @@ function EditMemberDrawer({
|
||||
context: 'Roles update',
|
||||
apiError: toSaveApiError(rolesResult.reason),
|
||||
onRetry: async (): Promise<void> => {
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
const failures = await applyDiff([...localRoles], availableRoles);
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
return [
|
||||
@@ -353,9 +365,9 @@ function EditMemberDrawer({
|
||||
isDirty,
|
||||
isSelf,
|
||||
localDisplayName,
|
||||
localRole,
|
||||
localRoles,
|
||||
fetchedDisplayName,
|
||||
fetchedRoleIds,
|
||||
currentMemberRoles,
|
||||
updateMyUser,
|
||||
updateUser,
|
||||
applyDiff,
|
||||
@@ -503,10 +515,15 @@ function EditMemberDrawer({
|
||||
>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<div className="edit-member-drawer__disabled-roles">
|
||||
{localRole ? (
|
||||
<Badge color="vanilla">
|
||||
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
|
||||
</Badge>
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="edit-member-drawer__email-text">—</span>
|
||||
)}
|
||||
@@ -517,14 +534,15 @@ function EditMemberDrawer({
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="member-role"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
value={localRoles}
|
||||
onChange={(roles): void => {
|
||||
setLocalRoles(roles);
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
@@ -532,8 +550,7 @@ function EditMemberDrawer({
|
||||
),
|
||||
);
|
||||
}}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
placeholder="Select roles"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
useCreateResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetResetPasswordToken,
|
||||
useGetRolesByUserID,
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
@@ -23,11 +25,16 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useGetUser: jest.fn(),
|
||||
useGetRolesByUserID: jest.fn(),
|
||||
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useGetResetPasswordToken: jest.fn(),
|
||||
useCreateResetPasswordToken: jest.fn(),
|
||||
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
|
||||
`/api/v2/users/${id}/roles`,
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
|
||||
@@ -98,6 +105,7 @@ jest.mock('react-use', () => ({
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
const mockDeleteMutate = jest.fn();
|
||||
const mockRemoveMutateAsync = jest.fn();
|
||||
const mockCreateTokenMutateAsync = jest.fn();
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
@@ -186,6 +194,14 @@ describe('EditMemberDrawer', () => {
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
(useGetRolesByUserID as jest.Mock).mockReturnValue({
|
||||
data: { data: [managedRoles[0]] },
|
||||
isLoading: false,
|
||||
});
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
@@ -296,7 +312,7 @@ describe('EditMemberDrawer', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selecting a different role calls setRole with the new role name', async () => {
|
||||
it('adding a new role calls setRole without removing existing ones', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
@@ -308,7 +324,7 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Open the roles dropdown and select signoz-editor
|
||||
// signoz-admin is already selected; add signoz-editor on top
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-editor'));
|
||||
|
||||
@@ -321,34 +337,31 @@ describe('EditMemberDrawer', () => {
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-editor' },
|
||||
});
|
||||
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call removeRole when the role is changed', async () => {
|
||||
it('deselecting a role calls removeRole with the role id', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
// Switch from signoz-admin to signoz-viewer using single-select
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
// signoz-admin appears as a selected tag — click its remove button to deselect
|
||||
const adminTag = await screen.findByTitle('signoz-admin');
|
||||
const removeBtn = adminTag.querySelector(
|
||||
'.ant-select-selection-item-remove',
|
||||
) as Element;
|
||||
await user.click(removeBtn);
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-viewer' },
|
||||
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,49 @@
|
||||
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(--accent-primary);
|
||||
}
|
||||
|
||||
.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,8 +1,17 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { Dot, Sparkles } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
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 '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
@@ -29,6 +38,7 @@ function HeaderRightSection({
|
||||
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const handleOpenFeedbackModal = useCallback((): void => {
|
||||
logEvent('Feedback: Clicked', {
|
||||
@@ -67,9 +77,46 @@ 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}
|
||||
|
||||
<TooltipSimple title="AI Assistant">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
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={<Sparkles size={14} color="var(--primary)" />}
|
||||
>
|
||||
AI Assistant
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
@@ -83,12 +130,13 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="share-feedback-btn"
|
||||
aria-label="Feedback"
|
||||
prefix={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -105,9 +153,10 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Announcements"
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
prefix={<Inbox size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
@@ -130,12 +179,12 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-link-btn periscope-btn ghost"
|
||||
icon={<Globe size={14} />}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Share"
|
||||
prefix={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,10 @@ 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;
|
||||
|
||||
@@ -44,7 +44,11 @@ function HttpStatusBadge({
|
||||
|
||||
const color = getStatusCodeColor(numericStatusCode);
|
||||
|
||||
return <Badge color={color}>{statusCode}</Badge>;
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
{statusCode}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default HttpStatusBadge;
|
||||
|
||||
@@ -12,6 +12,7 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
|
||||
import a11yDark from 'react-syntax-highlighter/dist/esm/styles/prism/a11y-dark';
|
||||
import oneLight from 'react-syntax-highlighter/dist/esm/styles/prism/one-light';
|
||||
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('docker', docker);
|
||||
@@ -31,4 +32,4 @@ SyntaxHighlighter.registerLanguage('yaml', yaml);
|
||||
SyntaxHighlighter.registerLanguage('yml', yaml);
|
||||
|
||||
export default SyntaxHighlighter;
|
||||
export { a11yDark };
|
||||
export { a11yDark, oneLight };
|
||||
|
||||
@@ -1155,7 +1155,7 @@ describe('removeKeysFromExpression', () => {
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should handle multiple variable instances of same key', () => {
|
||||
it('should remove at most one variable expression per key', () => {
|
||||
const expression =
|
||||
"deployment.environment = $env1 deployment.environment = $env2 deployment.environment = 'default'";
|
||||
const result = removeKeysFromExpression(
|
||||
@@ -1164,9 +1164,11 @@ describe('removeKeysFromExpression', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
// Should remove one occurence as this case in itself is invalid to have multiple variable expressions for the same key
|
||||
// Should remove one occurrence — having multiple $-value clauses for the
|
||||
// same key is invalid. The first is removed; subsequent $ clauses and
|
||||
// literal-value clauses are preserved.
|
||||
expect(result).toBe(
|
||||
"deployment.environment = $env1 deployment.environment = 'default'",
|
||||
"deployment.environment = $env2 AND deployment.environment = 'default'",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1199,6 +1201,186 @@ describe('removeKeysFromExpression', () => {
|
||||
expect(pairs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANTLR-based removal — operator precedence (AND binds tighter than OR)', () => {
|
||||
it('preserves OR when removing from a mixed AND/OR expression', () => {
|
||||
// a AND b OR c — grammar parses as (a AND b) OR c
|
||||
// removing b collapses the AND group to just a, OR is preserved
|
||||
expect(
|
||||
removeKeysFromExpression("a = '1' AND b = '2' OR c = '3'", ['b']),
|
||||
).toBe("a = '1' OR c = '3'");
|
||||
});
|
||||
|
||||
it('preserves correct conjunctions in a four-term mixed expression', () => {
|
||||
// a AND b OR c AND d — removing b collapses first AND group to a
|
||||
expect(
|
||||
removeKeysFromExpression("a = '1' AND b = '2' OR c = '3' AND d = '4'", [
|
||||
'b',
|
||||
]),
|
||||
).toBe("a = '1' OR c = '3' AND d = '4'");
|
||||
});
|
||||
|
||||
it('preserves correct conjunctions when removing from a trailing AND group', () => {
|
||||
// a OR b AND c OR d — removing c collapses second AND group to b
|
||||
expect(
|
||||
removeKeysFromExpression("a = '1' OR b = '2' AND c = '3' OR d = '4'", [
|
||||
'c',
|
||||
]),
|
||||
).toBe("a = '1' OR b = '2' OR d = '4'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANTLR-based removal — parenthesised expressions', () => {
|
||||
it('removes last clause without leaving a dangling AND', () => {
|
||||
const expression =
|
||||
'(deployment.environment = $deployment.environment AND service.name = $service.name AND top_level_operation IN [$top_level_operation])';
|
||||
expect(
|
||||
removeKeysFromExpression(expression, ['top_level_operation'], true),
|
||||
).toBe(
|
||||
'(deployment.environment = $deployment.environment AND service.name = $service.name)',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes first clause without leaving a dangling AND', () => {
|
||||
expect(
|
||||
removeKeysFromExpression(
|
||||
'(deployment.environment = $deployment.environment AND service.name = $service.name)',
|
||||
['deployment.environment'],
|
||||
true,
|
||||
),
|
||||
).toBe('service.name = $service.name');
|
||||
});
|
||||
|
||||
it('removes middle clause without disturbing surrounding AND', () => {
|
||||
expect(
|
||||
removeKeysFromExpression(
|
||||
'(deployment.environment = $deployment.environment AND service.name = $service.name AND region = $region)',
|
||||
['service.name'],
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
'(deployment.environment = $deployment.environment AND region = $region)',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the empty paren group when its only child is removed', () => {
|
||||
// (a) OR (b) — removing a must not leave () OR (b = '2')
|
||||
// The remaining single-clause group has its redundant parens stripped too
|
||||
expect(removeKeysFromExpression("(a = '1') OR (b = '2')", ['a'])).toBe(
|
||||
"b = '2'",
|
||||
);
|
||||
});
|
||||
|
||||
it('handles OR inside parentheses without leaving dangling OR', () => {
|
||||
expect(
|
||||
removeKeysFromExpression(
|
||||
'(service.name = $service.name OR operation = $operation)',
|
||||
['operation'],
|
||||
true,
|
||||
),
|
||||
).toBe('service.name = $service.name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANTLR-based removal — BETWEEN, EXISTS, and other operators', () => {
|
||||
it('removes a BETWEEN clause without treating its AND as a conjunction', () => {
|
||||
// BETWEEN x AND y — the AND is part of the operator, not a conjunction
|
||||
expect(
|
||||
removeKeysFromExpression("a BETWEEN 1 AND 10 AND b = '2'", ['a']),
|
||||
).toBe("b = '2'");
|
||||
});
|
||||
|
||||
it('removes a NOT BETWEEN clause without treating its AND as a conjunction', () => {
|
||||
expect(
|
||||
removeKeysFromExpression("a NOT BETWEEN 1 AND 10 AND b = '2'", ['a']),
|
||||
).toBe("b = '2'");
|
||||
});
|
||||
|
||||
it('removes an EXISTS clause (no value token)', () => {
|
||||
expect(removeKeysFromExpression("a = '1' AND b EXISTS", ['b'])).toBe(
|
||||
"a = '1'",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a NOT EXISTS clause', () => {
|
||||
expect(removeKeysFromExpression("a = '1' AND b NOT EXISTS", ['b'])).toBe(
|
||||
"a = '1'",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes an IN clause correctly', () => {
|
||||
expect(
|
||||
removeKeysFromExpression("service IN ['api', 'web'] AND status = 'ok'", [
|
||||
'service',
|
||||
]),
|
||||
).toBe("status = 'ok'");
|
||||
});
|
||||
|
||||
it('removes a NOT IN clause correctly', () => {
|
||||
expect(
|
||||
removeKeysFromExpression(
|
||||
"service NOT IN ['api', 'web'] AND status = 'ok'",
|
||||
['service'],
|
||||
),
|
||||
).toBe("status = 'ok'");
|
||||
});
|
||||
|
||||
it('removes a CONTAINS clause correctly', () => {
|
||||
expect(
|
||||
removeKeysFromExpression("message CONTAINS 'error' AND service = 'api'", [
|
||||
'message',
|
||||
]),
|
||||
).toBe("service = 'api'");
|
||||
});
|
||||
|
||||
it('removes a LIKE clause correctly', () => {
|
||||
expect(
|
||||
removeKeysFromExpression("message LIKE '%error%' AND service = 'api'", [
|
||||
'message',
|
||||
]),
|
||||
).toBe("service = 'api'");
|
||||
});
|
||||
|
||||
it('removes a NOT LIKE clause correctly', () => {
|
||||
expect(
|
||||
removeKeysFromExpression("message NOT LIKE '%error%' AND service = 'api'", [
|
||||
'message',
|
||||
]),
|
||||
).toBe("service = 'api'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANTLR-based removal — AND/OR precedence combinations', () => {
|
||||
it('handles a AND b AND c OR d: removing b leaves a AND c OR d', () => {
|
||||
// AND binds tighter than OR, so this parses as (a AND b AND c) OR d
|
||||
expect(
|
||||
removeKeysFromExpression("a = '1' AND b = '2' AND c = '3' OR d = '4'", [
|
||||
'b',
|
||||
]),
|
||||
).toBe("a = '1' AND c = '3' OR d = '4'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANTLR-based removal — deeply nested expressions', () => {
|
||||
const nestedExpr =
|
||||
"((deployment.environment = $env1 OR deployment.environment = 'default') AND service.name = $svc1)";
|
||||
|
||||
it('removes service.name variable — outer and inner single-child parens both drop', () => {
|
||||
// After removal: inner OR group keeps parens (2 items), outer group drops
|
||||
// parens (1 item remains)
|
||||
expect(removeKeysFromExpression(nestedExpr, ['service.name'], true)).toBe(
|
||||
"(deployment.environment = $env1 OR deployment.environment = 'default')",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes deployment.environment variable — inner OR collapses, outer parens kept', () => {
|
||||
// Only the $env1 variable clause is removed; 'default' literal stays.
|
||||
// Inner paren drops (single item left), outer paren stays (2 AND items remain).
|
||||
expect(
|
||||
removeKeysFromExpression(nestedExpr, ['deployment.environment'], true),
|
||||
).toBe("(deployment.environment = 'default' AND service.name = $svc1)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValueForExpression', () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { CharStreams, CommonTokenStream, ParserRuleContext } from 'antlr4';
|
||||
import { cloneDeep, isEqual, sortBy } from 'lodash-es';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
|
||||
import {
|
||||
DEPRECATED_OPERATORS_MAP,
|
||||
@@ -6,7 +9,16 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep, isEqual, sortBy } from 'lodash-es';
|
||||
import FilterQueryLexer from 'parser/FilterQueryLexer';
|
||||
import FilterQueryParser, {
|
||||
AndExpressionContext,
|
||||
ComparisonContext,
|
||||
InClauseContext,
|
||||
NotInClauseContext,
|
||||
OrExpressionContext,
|
||||
PrimaryContext,
|
||||
UnaryExpressionContext,
|
||||
} from 'parser/FilterQueryParser';
|
||||
import { IQueryPair } from 'types/antlrQueryTypes';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
@@ -26,7 +38,6 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isQuoted, unquote } from 'utils/stringUtils';
|
||||
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
* Check if an operator requires array values (like IN, NOT IN)
|
||||
@@ -513,97 +524,201 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes specified key-value pairs from a logical query expression string.
|
||||
* Removes clauses for specified keys from a filter query expression.
|
||||
*
|
||||
* This function parses the given query expression and removes any query pairs
|
||||
* whose keys match those in the `keysToRemove` array. It also removes any trailing
|
||||
* logical conjunctions (e.g., `AND`, `OR`) and whitespace that follow the matched pairs,
|
||||
* ensuring that the resulting expression remains valid and clean.
|
||||
* Uses an ANTLR parse-tree traversal over the existing FilterQuery grammar so that
|
||||
* compound predicates like `BETWEEN x AND y`, `EXISTS`, and `IN (...)` are treated
|
||||
* as atomic nodes — their internal tokens are never confused with top-level AND/OR
|
||||
* conjunctions. Surviving siblings are rejoined with the correct operator at each
|
||||
* level of the tree, producing no dangling operators regardless of expression shape.
|
||||
* If the expression cannot be parsed, it is returned unchanged.
|
||||
*
|
||||
* @param expression - The full query string.
|
||||
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
|
||||
* @param removeOnlyVariableExpressions - When true, only removes key-value pairs where the value is a variable (starts with $). When false, uses the original behavior.
|
||||
* @returns A new expression string with the specified keys and their associated clauses removed.
|
||||
* @param expression - The full filter query string.
|
||||
* @param keysToRemove - Keys (case-insensitive) whose clauses should be dropped.
|
||||
* @param removeOnlyVariableExpressions - Controls which clauses are eligible for removal:
|
||||
* - `false` (default): removes all clauses for the key regardless of value.
|
||||
* - `true`: removes only the first clause whose value contains any `$`.
|
||||
* - `string` (e.g. `"$service.name"`): removes only the clause whose value exactly
|
||||
* matches that string — preferred when the specific variable reference is known.
|
||||
* @returns The rewritten expression, or an empty string if all clauses were removed.
|
||||
*/
|
||||
export const removeKeysFromExpression = (
|
||||
expression: string,
|
||||
keysToRemove: string[],
|
||||
removeOnlyVariableExpressions = false,
|
||||
removeOnlyVariableExpressions: string | boolean = false,
|
||||
): string => {
|
||||
if (!keysToRemove || keysToRemove.length === 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
let updatedExpression = expression;
|
||||
|
||||
if (updatedExpression) {
|
||||
keysToRemove.forEach((key) => {
|
||||
// Extract key-value query pairs from the expression
|
||||
const existingQueryPairs = extractQueryPairs(updatedExpression);
|
||||
|
||||
let queryPairsMap: Map<string, IQueryPair>;
|
||||
|
||||
if (existingQueryPairs.length > 0) {
|
||||
// Filter query pairs based on the removeOnlyVariableExpressions flag
|
||||
const filteredQueryPairs = removeOnlyVariableExpressions
|
||||
? existingQueryPairs.filter((pair) => {
|
||||
const pairKey = pair.key?.trim().toLowerCase();
|
||||
const matchesKey = pairKey === `${key}`.trim().toLowerCase();
|
||||
if (!matchesKey) {
|
||||
return false;
|
||||
}
|
||||
const value = pair.value?.toString().trim();
|
||||
return value && value.includes('$');
|
||||
})
|
||||
: existingQueryPairs;
|
||||
|
||||
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
|
||||
queryPairsMap = new Map(
|
||||
filteredQueryPairs.map((pair) => {
|
||||
const key = pair.key.trim().toLowerCase();
|
||||
return [key, pair];
|
||||
}),
|
||||
);
|
||||
|
||||
// Lookup the current query pair using the attribute key (case-insensitive)
|
||||
const currentQueryPair = queryPairsMap.get(`${key}`.trim().toLowerCase());
|
||||
if (currentQueryPair && currentQueryPair.isComplete) {
|
||||
// Determine the start index of the query pair (fallback order: key → operator → value)
|
||||
const queryPairStart =
|
||||
currentQueryPair.position.keyStart ??
|
||||
currentQueryPair.position.operatorStart ??
|
||||
currentQueryPair.position.valueStart;
|
||||
// Determine the end index of the query pair (fallback order: value → operator → key)
|
||||
let queryPairEnd =
|
||||
currentQueryPair.position.valueEnd ??
|
||||
currentQueryPair.position.operatorEnd ??
|
||||
currentQueryPair.position.keyEnd;
|
||||
// Get the part of the expression that comes after the current query pair
|
||||
const expressionAfterPair = `${updatedExpression.slice(queryPairEnd + 1)}`;
|
||||
// Match optional spaces and an optional conjunction (AND/OR), case-insensitive
|
||||
const conjunctionOrSpacesRegex = /^(\s*((AND|OR)\s+)?)/i;
|
||||
const match = expressionAfterPair.match(conjunctionOrSpacesRegex);
|
||||
if (match && match.length > 0) {
|
||||
// If match is found, extend the queryPairEnd to include the matched part
|
||||
queryPairEnd += match[0].length;
|
||||
}
|
||||
// Remove the full query pair (including any conjunction/whitespace) from the expression
|
||||
updatedExpression = `${updatedExpression.slice(
|
||||
0,
|
||||
queryPairStart,
|
||||
)}${updatedExpression.slice(queryPairEnd + 1)}`.trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up any remaining trailing AND/OR operators and extra whitespace
|
||||
updatedExpression = updatedExpression
|
||||
.replace(/\s+(AND|OR)\s*$/i, '') // Remove trailing AND/OR
|
||||
.replace(/^(AND|OR)\s+/i, '') // Remove leading AND/OR
|
||||
.trim();
|
||||
if (!expression.trim()) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return updatedExpression;
|
||||
const keysSet = new Set(keysToRemove.map((k) => k.trim().toLowerCase()));
|
||||
// Tracks keys for which a variable expression has already been removed.
|
||||
// Having multiple $-value clauses for the same key is invalid; we remove at most one.
|
||||
const removedVariableKeys = new Set<string>();
|
||||
|
||||
const chars = CharStreams.fromString(expression);
|
||||
const lexer = new FilterQueryLexer(chars);
|
||||
lexer.removeErrorListeners();
|
||||
const tokenStream = new CommonTokenStream(lexer);
|
||||
const parser = new FilterQueryParser(tokenStream);
|
||||
parser.removeErrorListeners();
|
||||
|
||||
const tree = parser.query();
|
||||
|
||||
// If the expression couldn't be parsed, return it unchanged rather than mangling it
|
||||
if (parser.syntaxErrorsCount > 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
// Extract original source text for a node, preserving the user's exact formatting
|
||||
const src = (ctx: ParserRuleContext): string =>
|
||||
expression.slice(ctx.start.start, (ctx.stop ?? ctx.start).stop + 1);
|
||||
|
||||
// Returns null when the entire node should be dropped.
|
||||
// isSingle = true means the result is a single, non-compound expression at
|
||||
// this level (no AND/OR between sibling clauses), which lets the paren
|
||||
// visitor decide whether wrapping is still needed.
|
||||
type VisitResult = { text: string; isSingle: boolean } | null;
|
||||
|
||||
function visitOrExpression(ctx: OrExpressionContext): VisitResult {
|
||||
const parts = ctx
|
||||
.andExpression_list()
|
||||
.map(visitAndExpression)
|
||||
.filter((r): r is NonNullable<VisitResult> => r !== null);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Single surviving branch — pass its isSingle straight through so the
|
||||
// paren visitor can decide whether to keep the outer parens.
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
return { text: parts.map((p) => p.text).join(' OR '), isSingle: false };
|
||||
}
|
||||
|
||||
function visitAndExpression(ctx: AndExpressionContext): VisitResult {
|
||||
const parts = ctx
|
||||
.unaryExpression_list()
|
||||
.map(visitUnaryExpression)
|
||||
.filter((r): r is NonNullable<VisitResult> => r !== null);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return { text: parts[0].text, isSingle: true };
|
||||
}
|
||||
return { text: parts.map((p) => p.text).join(' AND '), isSingle: false };
|
||||
}
|
||||
|
||||
function visitUnaryExpression(ctx: UnaryExpressionContext): VisitResult {
|
||||
const primaryResult = visitPrimary(ctx.primary());
|
||||
if (primaryResult === null) {
|
||||
return null;
|
||||
}
|
||||
return ctx.NOT()
|
||||
? { text: `NOT ${primaryResult.text}`, isSingle: true }
|
||||
: primaryResult;
|
||||
}
|
||||
|
||||
function visitPrimary(ctx: PrimaryContext): VisitResult {
|
||||
// Parenthesised sub-expression: ( orExpression )
|
||||
const orCtx = ctx.orExpression();
|
||||
if (orCtx) {
|
||||
const inner = visitOrExpression(orCtx);
|
||||
if (inner === null) {
|
||||
return null;
|
||||
}
|
||||
// Drop redundant parens when the group collapses to a single clause;
|
||||
// keep them when multiple clauses remain (operator-precedence matters).
|
||||
if (inner.isSingle) {
|
||||
return { text: inner.text, isSingle: true };
|
||||
}
|
||||
return { text: `(${inner.text})`, isSingle: true };
|
||||
}
|
||||
|
||||
const compCtx = ctx.comparison();
|
||||
if (compCtx) {
|
||||
const result = visitComparison(compCtx);
|
||||
return result !== null ? { text: result, isSingle: true } : null;
|
||||
}
|
||||
|
||||
// functionCall, fullText, bare key, bare value — keep verbatim
|
||||
return { text: src(ctx), isSingle: true };
|
||||
}
|
||||
|
||||
function visitComparison(ctx: ComparisonContext): string | null {
|
||||
const keyText = ctx.key().getText().trim().toLowerCase();
|
||||
|
||||
if (!keysSet.has(keyText)) {
|
||||
return src(ctx);
|
||||
}
|
||||
|
||||
if (removeOnlyVariableExpressions) {
|
||||
// Scope the value check to value nodes only — not the full comparison text —
|
||||
// so a key that contains '$' does not trigger removal when the value is a
|
||||
// literal. The ANTLR4 runtime returns null from getTypedRuleContext when a
|
||||
// rule is absent, despite the non-nullable TypeScript signatures.
|
||||
const inCtx = ctx.inClause() as unknown as InClauseContext | null;
|
||||
const notInCtx = ctx.notInClause() as unknown as NotInClauseContext | null;
|
||||
// When a specific variable string is supplied, require an exact match so we
|
||||
// never accidentally remove a different $-valued clause for the same key.
|
||||
const matchesVariable = (text: string): boolean =>
|
||||
typeof removeOnlyVariableExpressions === 'string'
|
||||
? text === removeOnlyVariableExpressions
|
||||
: text.includes('$');
|
||||
|
||||
const valueHasVariable = (): boolean => {
|
||||
// Simple comparisons: key = $var, BETWEEN $v1 AND $v2, etc.
|
||||
if (ctx.value_list().some((v) => matchesVariable(v.getText()))) {
|
||||
return true;
|
||||
}
|
||||
// IN $var (bare single value) or IN ($v1, $v2) (value list)
|
||||
if (inCtx) {
|
||||
const bare = inCtx.value() as unknown as { getText(): string } | null;
|
||||
if (bare && matchesVariable(bare.getText())) {
|
||||
return true;
|
||||
}
|
||||
const list = inCtx.valueList() as unknown as {
|
||||
value_list(): { getText(): string }[];
|
||||
} | null;
|
||||
if (list && list.value_list().some((v) => matchesVariable(v.getText()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// NOT IN $var or NOT IN ($v1, $v2)
|
||||
if (notInCtx) {
|
||||
const bare = notInCtx.value() as unknown as { getText(): string } | null;
|
||||
if (bare && matchesVariable(bare.getText())) {
|
||||
return true;
|
||||
}
|
||||
const list = notInCtx.valueList() as unknown as {
|
||||
value_list(): { getText(): string }[];
|
||||
} | null;
|
||||
if (list && list.value_list().some((v) => matchesVariable(v.getText()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (valueHasVariable()) {
|
||||
if (removedVariableKeys.has(keyText)) {
|
||||
return src(ctx);
|
||||
}
|
||||
removedVariableKeys.add(keyText);
|
||||
return null;
|
||||
}
|
||||
return src(ctx);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = visitOrExpression(tree.expression().orExpression());
|
||||
return result?.text ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -360,8 +360,7 @@ describe('createGuardedRoute', () => {
|
||||
const obj = payload[0]?.object;
|
||||
const kind = obj?.resource?.kind;
|
||||
const selector = obj?.selector ?? '*';
|
||||
const objectStr =
|
||||
obj?.resource?.type === 'metaresources' ? kind : `${kind}:${selector}`;
|
||||
const objectStr = `${kind}:${selector}`;
|
||||
requestedObjects.push(objectStr ?? '');
|
||||
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
|
||||
@@ -38,6 +38,8 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ 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;
|
||||
|
||||
|
||||
@@ -3,5 +3,7 @@ export const USER_PREFERENCES = {
|
||||
NAV_SHORTCUTS: 'nav_shortcuts',
|
||||
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
SPAN_DETAILS_PREVIEW_ATTRIBUTES: 'span_details_preview_attributes',
|
||||
SPAN_DETAILS_COLOR_BY_ATTRIBUTE: 'span_details_color_by_attribute',
|
||||
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
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 { VariantContext } from '../VariantContext';
|
||||
|
||||
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}
|
||||
// Suppress default close button — we render our own header
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div>
|
||||
<div>
|
||||
<MessageSquare size={16} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TooltipSimple title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNewConversation}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VariantContext.Provider value="panel">
|
||||
{activeConversationId ? (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
) : null}
|
||||
</VariantContext.Provider>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AIAssistantDrawer';
|
||||
export { default } from './AIAssistantDrawer';
|
||||
@@ -0,0 +1,98 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: backdropIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes backdropIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 70vw;
|
||||
height: 80vh;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
|
||||
animation: modalIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-weight: 500;
|
||||
color: var(--l3-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 1px 5px;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.6;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toggleBtnActive {
|
||||
background: var(--l2-background) !important;
|
||||
color: var(--accent-primary) !important;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
|
||||
|
||||
import HistorySidebar from '../components/ConversationsList';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { VariantContext } from '../VariantContext';
|
||||
|
||||
import styles from './AIAssistantModal.module.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,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// Cmd+J (Mac) / Ctrl+J (Win/Linux) — toggle modal. Opening
|
||||
// always starts a brand-new conversation; resuming earlier
|
||||
// threads is done via the in-modal history sidebar.
|
||||
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 {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
openModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — close modal
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, openModal, closeModal, startNewConversation]);
|
||||
|
||||
// ── 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],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<VariantContext.Provider value="modal">
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.modal}>
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Sparkles size={16} color="var(--primary)" />
|
||||
<span>AI Assistant</span>
|
||||
<kbd className={styles.shortcut}>
|
||||
<span>⌘</span>
|
||||
<span>J</span>
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple title={showHistory ? 'Back to chat' : 'Conversations'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle conversations"
|
||||
className={showHistory ? styles.toggleBtnActive : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={styles.body}>
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VariantContext.Provider>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AIAssistantModal';
|
||||
export { default } from './AIAssistantModal';
|
||||
@@ -0,0 +1,60 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 1px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
|
||||
|
||||
import ConversationsList from '../components/ConversationsList';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { VariantContext } from '../VariantContext';
|
||||
|
||||
import styles from './AIAssistantPanel.module.scss';
|
||||
|
||||
const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
|
||||
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
|
||||
|
||||
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);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shouldOffsetChatSupport = isOpen && !isFullScreenPage;
|
||||
|
||||
document.body.classList.toggle(
|
||||
AI_ASSISTANT_PANEL_OPEN_CLASS,
|
||||
shouldOffsetChatSupport,
|
||||
);
|
||||
|
||||
if (shouldOffsetChatSupport) {
|
||||
document.body.style.setProperty(
|
||||
AI_ASSISTANT_PANEL_WIDTH_VAR,
|
||||
`${panelWidth}px`,
|
||||
);
|
||||
} else {
|
||||
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove(AI_ASSISTANT_PANEL_OPEN_CLASS);
|
||||
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
|
||||
};
|
||||
}, [isFullScreenPage, isOpen, panelWidth]);
|
||||
|
||||
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 (
|
||||
<VariantContext.Provider value="panel">
|
||||
<div className={styles.panel} style={{ width: panelWidth }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<Sparkles size={18} color="var(--primary)" />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple 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>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory ? (
|
||||
<ConversationsList onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</VariantContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AIAssistantPanel';
|
||||
export { default } from './AIAssistantPanel';
|
||||
@@ -0,0 +1,32 @@
|
||||
.trigger {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background: var(--accent-primary);
|
||||
color: var(--accent-primary-foreground);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Bot } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from '../store/useAIAssistantStore';
|
||||
|
||||
import styles from './AIAssistantTrigger.module.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 (
|
||||
<TooltipSimple title="AI Assistant">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.trigger}
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Bot size={20} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AIAssistantTrigger';
|
||||
export { default } from './AIAssistantTrigger';
|
||||
@@ -0,0 +1,53 @@
|
||||
.conversation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
&.compact {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
|
||||
&.compact {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ChatInput, { autoContextKey } from '../components/ChatInput';
|
||||
import ConversationSkeleton from '../components/ConversationSkeleton';
|
||||
import VirtualizedMessages from '../components/VirtualizedMessages';
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { MessageAttachment } from '../types';
|
||||
import { MessageContext } from '../../../api/ai-assistant/chat';
|
||||
import { useVariant } from '../VariantContext';
|
||||
|
||||
import styles from './ConversationView.module.scss';
|
||||
|
||||
interface ConversationViewProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export default function ConversationView({
|
||||
conversationId,
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const location = useLocation();
|
||||
|
||||
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);
|
||||
|
||||
// Auto-derived contexts come from the route the user is currently looking
|
||||
// at (dashboard detail, service metrics, an explorer, …). Skip when the
|
||||
// user is on the standalone AI Assistant page — there's no "underlying"
|
||||
// page context to attach. ChatInput renders these as chips and merges
|
||||
// them with the user's `@`-mention picks before invoking onSend.
|
||||
const allAutoContexts = useMemo(
|
||||
() =>
|
||||
variant === 'page'
|
||||
? []
|
||||
: getAutoContexts(location.pathname, location.search),
|
||||
[variant, location.pathname, location.search],
|
||||
);
|
||||
|
||||
// User-dismissed auto-context entries. Reset whenever the URL changes —
|
||||
// dismissals are scoped to "this page", not the whole conversation.
|
||||
const [dismissedAutoKeys, setDismissedAutoKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setDismissedAutoKeys(new Set());
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
const autoContexts = useMemo(
|
||||
() =>
|
||||
allAutoContexts.filter((ctx) => !dismissedAutoKeys.has(autoContextKey(ctx))),
|
||||
[allAutoContexts, dismissedAutoKeys],
|
||||
);
|
||||
|
||||
const handleDismissAutoContext = useCallback((key: string): void => {
|
||||
setDismissedAutoKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
const inputWrapperClass = cx(styles.inputWrapper, {
|
||||
[styles.compact]: isCompact,
|
||||
});
|
||||
const disclaimerClass = cx(styles.disclaimer, {
|
||||
[styles.compact]: isCompact,
|
||||
});
|
||||
|
||||
// Cover the gap between rehydrate (empty primed entry) and the first
|
||||
// loadThread response. `isHydrating` is set on the rehydrated conversation
|
||||
// and cleared once fetchThreads resolves; `isLoadingThread` covers the
|
||||
// per-thread fetch that follows. Together they keep the skeleton visible
|
||||
// for persisted chats without flashing it on freshly-created ones.
|
||||
const isHydrating = Boolean(conversation?.isHydrating);
|
||||
if ((isLoadingThread || isHydrating) && messages.length === 0) {
|
||||
return (
|
||||
<div className={styles.conversation}>
|
||||
<ConversationSkeleton />
|
||||
<div className={inputWrapperClass}>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
disabled
|
||||
autoContexts={autoContexts}
|
||||
onDismissAutoContext={handleDismissAutoContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.conversation}>
|
||||
<VirtualizedMessages
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
{showDisclaimer && (
|
||||
<div className={disclaimerClass} role="note" aria-live="polite">
|
||||
SigNoz AI can make mistakes. Please double-check responses.
|
||||
</div>
|
||||
)}
|
||||
<div className={inputWrapperClass}>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
disabled={inputDisabled}
|
||||
isStreaming={isStreamingHere}
|
||||
autoContexts={autoContexts}
|
||||
onDismissAutoContext={handleDismissAutoContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ConversationView';
|
||||
export { default } from './ConversationView';
|
||||
8
frontend/src/container/AIAssistant/VariantContext.ts
Normal file
8
frontend/src/container/AIAssistant/VariantContext.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AIAssistantVariant = 'panel' | 'page' | 'modal';
|
||||
|
||||
export const VariantContext = createContext<AIAssistantVariant>('page');
|
||||
|
||||
export const useVariant = (): AIAssistantVariant => useContext(VariantContext);
|
||||
32
frontend/src/container/AIAssistant/_scrollbar.scss
Normal file
32
frontend/src/container/AIAssistant/_scrollbar.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@mixin scrollbar($width: 0.3rem) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
scrollbar-color: var(--l3-border) transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: $width;
|
||||
height: $width;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-border);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
// Background, padding-x, and rounding are inherited from the parent
|
||||
// bubble — the section sits inside the assistant bubble as its last
|
||||
// block, so it matches the bubble's width by definition.
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.headingIcon {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: -0.055px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
border-color 0.12s ease,
|
||||
color 0.12s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--accent-cherry);
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) svg {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
&.error svg {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
color: var(--accent-primary) !important;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.chipState {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--l3-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 999px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import cx from 'classnames';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
restoreExecution,
|
||||
revertExecution,
|
||||
undoExecution,
|
||||
} from 'api/ai-assistant/chat';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import {
|
||||
ArchiveRestore,
|
||||
BookOpen,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Filter,
|
||||
LoaderCircle,
|
||||
MessageCircle,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
Undo,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
interface ActionsSectionProps {
|
||||
actions: MessageActionDTO[];
|
||||
}
|
||||
|
||||
type ChipState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
interface ChipResult {
|
||||
state: ChipState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Maps each MessageActionKindDTO to its display icon. */
|
||||
function ActionIcon({
|
||||
kind,
|
||||
size = 12,
|
||||
}: {
|
||||
kind: MessageActionDTO['kind'];
|
||||
size?: number;
|
||||
}): JSX.Element {
|
||||
switch (kind) {
|
||||
case MessageActionKindDTO.undo:
|
||||
return <Undo size={size} />;
|
||||
case MessageActionKindDTO.revert:
|
||||
return <RotateCcw size={size} />;
|
||||
case MessageActionKindDTO.restore:
|
||||
return <ArchiveRestore size={size} />;
|
||||
case MessageActionKindDTO.follow_up:
|
||||
return <MessageCircle size={size} />;
|
||||
case MessageActionKindDTO.open_resource:
|
||||
return <Eye size={size} />;
|
||||
case MessageActionKindDTO.open_docs:
|
||||
return <BookOpen size={size} />;
|
||||
case MessageActionKindDTO.apply_filter:
|
||||
return <Filter size={size} />;
|
||||
default:
|
||||
return <ExternalLink size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an `open_resource` action to an in-app route.
|
||||
* Resource taxonomy mirrors `MessageContextDTOType`: dashboard, alert,
|
||||
* saved_view, service, and the *_explorer signals.
|
||||
*/
|
||||
function resourceRoute(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
): string | null {
|
||||
switch (resourceType) {
|
||||
case 'dashboard':
|
||||
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
|
||||
case 'alert': {
|
||||
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
|
||||
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
|
||||
}
|
||||
case 'service':
|
||||
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
|
||||
case 'saved_view':
|
||||
// No detail route — saved views land on the list page.
|
||||
// Caller may provide signal-aware metadata in future; default to logs.
|
||||
return ROUTES.LOGS_SAVE_VIEWS;
|
||||
case 'logs_explorer':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'traces_explorer':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case 'metrics_explorer':
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent emits `action.query` as the SigNoz REST query-range request body:
|
||||
*
|
||||
* - V5 (current backend): `{ ..., compositeQuery: { queries: [{ type, spec }] } }`
|
||||
* — each `spec` already carries `filter.expression` directly.
|
||||
* - V3 (legacy): `{ ..., compositeQuery: { builderQueries: { A: {...} } } }`
|
||||
*
|
||||
* The URL's `compositeQuery` param expects the in-app shape
|
||||
* (`{ queryType, builder: { queryData: [...], queryFormulas, queryTraceOperator }, ... }`).
|
||||
* `mapQueryDataFromApi` already handles both API shapes for query-range
|
||||
* responses, so we delegate to it instead of maintaining a parallel translator.
|
||||
*
|
||||
* Defensive: if the agent ever sends the URL shape directly (top-level
|
||||
* `builder.queryData`), we pass it through unchanged.
|
||||
*/
|
||||
function toUrlCompositeQuery(
|
||||
actionQuery: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
// Already in URL shape — use as-is (with envelope defaults filled in).
|
||||
if (
|
||||
actionQuery.builder &&
|
||||
typeof actionQuery.builder === 'object' &&
|
||||
Array.isArray((actionQuery.builder as Record<string, unknown>).queryData)
|
||||
) {
|
||||
return {
|
||||
queryType: actionQuery.queryType ?? 'builder',
|
||||
promql: actionQuery.promql ?? [],
|
||||
clickhouse_sql: actionQuery.clickhouse_sql ?? [],
|
||||
id: uuidv4(),
|
||||
unit: actionQuery.unit ?? '',
|
||||
...actionQuery,
|
||||
};
|
||||
}
|
||||
|
||||
// API shape: extract the inner compositeQuery and let the shared mapper
|
||||
// normalise V3/V5 spec → IBuilderQuery for us.
|
||||
const composite = (actionQuery.compositeQuery ?? actionQuery) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!composite) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const mapped = mapQueryDataFromApi(
|
||||
composite as unknown as ICompositeMetricQuery,
|
||||
);
|
||||
// `mapQueryDataFromApi` falls back to `initialQueryState.builder` when
|
||||
// neither `queries` nor `builderQueries` is present — detect that and
|
||||
// signal "unrecognised payload" instead of silently navigating to an
|
||||
// empty query.
|
||||
if (mapped.builder.queryData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return mapped as unknown as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks apply_filter action keys that have already been auto-applied so we
|
||||
* don't re-fire on re-renders / re-mounts. Module-level (intentionally) — it's
|
||||
* not state we'd ever want to reset on a component unmount; the action's
|
||||
* filters are already on the URL after the first auto-apply.
|
||||
*/
|
||||
const autoAppliedFilterKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* True when the user is currently on the explorer that an apply_filter
|
||||
* action targets — i.e. when auto-applying makes sense (the page is mounted
|
||||
* and ready to react to a URL change without a route transition).
|
||||
*/
|
||||
function signalMatchesPathname(
|
||||
signal: ApplyFilterSignalDTO,
|
||||
pathname: string,
|
||||
): boolean {
|
||||
switch (signal) {
|
||||
case ApplyFilterSignalDTO.logs:
|
||||
return Boolean(
|
||||
matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false }),
|
||||
);
|
||||
case ApplyFilterSignalDTO.traces:
|
||||
return Boolean(
|
||||
matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false }),
|
||||
);
|
||||
case ApplyFilterSignalDTO.metrics:
|
||||
return Boolean(
|
||||
matchPath(pathname, {
|
||||
path: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
exact: false,
|
||||
}),
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-action key used both to dedupe auto-applies and as the React key
|
||||
* for the chip. Mirrors the same construction we do in the render loop below.
|
||||
*/
|
||||
function actionKey(action: MessageActionDTO, index: number): string {
|
||||
return action.actionMetadataId
|
||||
? `${action.kind}:${action.actionMetadataId}`
|
||||
: `${action.kind}:${action.label}:${index}`;
|
||||
}
|
||||
|
||||
/** Maps a signal to its target explorer route. */
|
||||
function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
|
||||
switch (signal) {
|
||||
case ApplyFilterSignalDTO.logs:
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case ApplyFilterSignalDTO.traces:
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case ApplyFilterSignalDTO.metrics:
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApplyFilterDeps {
|
||||
history: ReturnType<typeof useHistory>;
|
||||
pathname: string;
|
||||
redirectWithQueryBuilderData: ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>['redirectWithQueryBuilderData'];
|
||||
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The V5 query-builder UI binds the WHERE clause CodeMirror editor to
|
||||
* `builder.queryData[i].filter.expression`. The agent normally only sends
|
||||
* `filters.items`, so we derive the expression per query before pushing
|
||||
* state. Same recipe as `pages/<X>/aiActions.ts` — keeps the immediate
|
||||
* UI update consistent with what the URL parser would produce on reload.
|
||||
*/
|
||||
function withDerivedFilterExpressions(query: Query): Query {
|
||||
const queryData = query.builder.queryData.map((q): IBuilderQuery => {
|
||||
const items = q.filters?.items ?? [];
|
||||
if (items.length === 0) {
|
||||
return q;
|
||||
}
|
||||
const filters: TagFilter = { items, op: q.filters?.op || 'AND' };
|
||||
return {
|
||||
...q,
|
||||
filters,
|
||||
filter: convertFiltersToExpression(filters),
|
||||
};
|
||||
});
|
||||
return { ...query, builder: { ...query.builder, queryData } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for an apply_filter action — used by both the auto-apply
|
||||
* effect (fired once when the user is already on the matching explorer) and
|
||||
* the manual chip-click handler.
|
||||
*
|
||||
* - On-page: push each builder query into the QueryBuilder provider via
|
||||
* `handleSetQueryData` so the WHERE clause re-renders immediately, then
|
||||
* `redirectWithQueryBuilderData` to persist it on the URL. Mirrors the
|
||||
* page-action recipe — calling redirect alone is not sufficient because
|
||||
* the URL→state effect runs after the next render and the editor binds
|
||||
* to `filter.expression`, not `filters.items`.
|
||||
* - Off-page: use `history.push` so the landing explorer initializes from
|
||||
* the new URL on mount.
|
||||
*/
|
||||
function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] enter', {
|
||||
signal: action.signal,
|
||||
query: action.query,
|
||||
pathname: deps.pathname,
|
||||
});
|
||||
if (!action.signal || !action.query) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[apply_filter] bail: missing signal or query', action);
|
||||
return;
|
||||
}
|
||||
const urlQuery = toUrlCompositeQuery(action.query as Record<string, unknown>);
|
||||
if (!urlQuery) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[apply_filter] bail: toUrlCompositeQuery returned null — agent payload shape unrecognized',
|
||||
action.query,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] normalized', normalized);
|
||||
if (signalMatchesPathname(action.signal, deps.pathname)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
|
||||
normalized.builder.queryData.forEach((q, i) => {
|
||||
deps.handleSetQueryData(i, q);
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(normalized);
|
||||
return;
|
||||
}
|
||||
const base = explorerRouteForSignal(action.signal);
|
||||
if (!base) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[apply_filter] bail: no route for signal', action.signal);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → history.push', base);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
function rollbackCall(
|
||||
kind: MessageActionDTO['kind'],
|
||||
): ((id: string) => Promise<unknown>) | null {
|
||||
switch (kind) {
|
||||
case MessageActionKindDTO.undo:
|
||||
return undoExecution;
|
||||
case MessageActionKindDTO.revert:
|
||||
return revertExecution;
|
||||
case MessageActionKindDTO.restore:
|
||||
return restoreExecution;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the actions attached to a single assistant message.
|
||||
*
|
||||
* Hidden when the message has no actions. Rendered inside `MessageBubble`
|
||||
* between the message body and the feedback bar.
|
||||
*/
|
||||
export default function ActionsSection({
|
||||
actions,
|
||||
}: ActionsSectionProps): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
// Per-chip click state, keyed by chip key (see `key` below). Persists
|
||||
// loading/success/error so the chip reflects the rollback outcome until
|
||||
// the underlying action.state catches up via a fresh thread fetch.
|
||||
const [results, setResults] = useState<Record<string, ChipResult>>({});
|
||||
|
||||
// Auto-apply any apply_filter action whose signal matches the page the
|
||||
// user is currently on (logs/traces/metrics explorer). Same code path as
|
||||
// the manual click below — just fired automatically once. The chip stays
|
||||
// clickable as a fallback for the off-page case. Dedupes via a module-
|
||||
// level set so re-renders / re-mounts don't re-fire.
|
||||
useEffect(() => {
|
||||
actions.forEach((action, i) => {
|
||||
if (action.kind !== MessageActionKindDTO.apply_filter) {
|
||||
return;
|
||||
}
|
||||
if (!action.signal || !action.query) {
|
||||
return;
|
||||
}
|
||||
if (!signalMatchesPathname(action.signal, pathname)) {
|
||||
return;
|
||||
}
|
||||
const key = actionKey(action, i);
|
||||
if (autoAppliedFilterKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
autoAppliedFilterKeys.add(key);
|
||||
applyFilter(action, {
|
||||
history,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
});
|
||||
});
|
||||
}, [
|
||||
actions,
|
||||
pathname,
|
||||
history,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setResult = (key: string, result: ChipResult): void => {
|
||||
setResults((prev) => ({ ...prev, [key]: result }));
|
||||
};
|
||||
|
||||
const runRollback = async (
|
||||
key: string,
|
||||
action: MessageActionDTO,
|
||||
): Promise<void> => {
|
||||
const call = rollbackCall(action.kind);
|
||||
if (!call || !action.actionMetadataId) {
|
||||
return;
|
||||
}
|
||||
setResult(key, { state: 'loading' });
|
||||
try {
|
||||
await call(action.actionMetadataId);
|
||||
setResult(key, { state: 'success' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed';
|
||||
setResult(key, { state: 'error', error: message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
switch (action.kind) {
|
||||
case MessageActionKindDTO.open_docs: {
|
||||
if (action.url) {
|
||||
openInNewTab(action.url);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.follow_up: {
|
||||
if (action.label) {
|
||||
void sendMessage(action.label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.open_resource: {
|
||||
if (action.resourceType && action.resourceId) {
|
||||
const path = resourceRoute(action.resourceType, action.resourceId);
|
||||
if (path) {
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.undo:
|
||||
case MessageActionKindDTO.revert:
|
||||
case MessageActionKindDTO.restore: {
|
||||
void runRollback(key, action);
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.apply_filter: {
|
||||
applyFilter(action, {
|
||||
history,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.heading}>
|
||||
<Sparkles size={12} className={styles.headingIcon} />
|
||||
<span className={styles.headingText}>Suggested actions</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.list}>
|
||||
{actions.map((action, i) => {
|
||||
// Stable per-action key (shared with the auto-apply dedupe set).
|
||||
// `actionMetadataId` alone isn't unique — the server can attach
|
||||
// the same id to multiple kinds (e.g. an `undo` and `revert` chip
|
||||
// for the same operation), so we always include the kind. Falls
|
||||
// back to label + index when the id is missing (e.g. follow_up /
|
||||
// open_docs).
|
||||
const key = actionKey(action, i);
|
||||
const result = results[key];
|
||||
const isLoading = result?.state === 'loading';
|
||||
const isSuccess = result?.state === 'success';
|
||||
const isError = result?.state === 'error';
|
||||
// `action.state` is a free-form string from the server (e.g. "active",
|
||||
// "applied"). Without a documented terminal vocabulary we don't auto-
|
||||
// disable on it — only the local in-flight click result does. The state
|
||||
// is still surfaced visually via the suffix pill below.
|
||||
const isDisabled = isLoading || isSuccess;
|
||||
|
||||
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
|
||||
|
||||
let icon: JSX.Element;
|
||||
if (isLoading) {
|
||||
icon = <LoaderCircle size={12} className={styles.spin} />;
|
||||
} else if (isSuccess) {
|
||||
icon = <Check size={12} />;
|
||||
} else if (isError) {
|
||||
icon = <TriangleAlert size={12} />;
|
||||
} else {
|
||||
icon = <ActionIcon kind={action.kind} />;
|
||||
}
|
||||
|
||||
const chip = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={cx(styles.chip, { [styles.error]: isError })}
|
||||
onClick={(): void => handleClick(key, action)}
|
||||
disabled={isDisabled}
|
||||
aria-label={action.label}
|
||||
prefix={icon}
|
||||
>
|
||||
<span className={styles.chipLabel}>{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<TooltipSimple key={key} title={tooltip}>
|
||||
{chip}
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<span key={key}>{chip}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ActionsSection';
|
||||
export { default } from './ActionsSection';
|
||||
@@ -0,0 +1,282 @@
|
||||
@use '../../_scrollbar' as *;
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 12px;
|
||||
background: var(--l1-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
&.decided {
|
||||
border-color: var(--l2-border);
|
||||
background: transparent;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.shieldIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.headerLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.resourceBadge {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 1px 5px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diffSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.diffHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diffHeaderLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.diff {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
// Fixed-height dialog (70vh) — let the diff fill the body and the
|
||||
// JSON panes scroll internally rather than pushing the dialog taller.
|
||||
&.expanded {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.diffBlock {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diffJson {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Unified view: a single column instead of two side-by-side blocks.
|
||||
// The block-level flex switches to column so the diff pane fills.
|
||||
&.unified {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.diffHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Container for line-by-line diff output. Mirrors `.diffJson` for scroll
|
||||
// + monospace styling but renders an inner stack of `.diffLine` rows
|
||||
// instead of a single `<pre>` so individual lines can be colored.
|
||||
.diffPane {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
border-radius: var(--radius-2);
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: var(--l2-foreground);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@include scrollbar(0.4rem);
|
||||
|
||||
&.wrapped .diffLineText {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.diffLine {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
min-height: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diffLineAdd {
|
||||
background: color-mix(in srgb, var(--accent-forest), transparent 88%);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.diffGutter {
|
||||
color: var(--accent-forest);
|
||||
}
|
||||
}
|
||||
|
||||
.diffLineRemove {
|
||||
background: color-mix(in srgb, var(--accent-cherry), transparent 88%);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.diffGutter {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
// Empty filler row in split view to keep before/after columns aligned
|
||||
// when one side has an added/removed line. Visible as a faint band so
|
||||
// the eye still tracks the row.
|
||||
.diffLinePlaceholder {
|
||||
background: color-mix(in srgb, var(--l3-foreground), transparent 94%);
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.diffGutter {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.diffLineText {
|
||||
white-space: pre;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diffBlock {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
|
||||
&.before .diffLabel {
|
||||
color: var(--accent-cherry);
|
||||
}
|
||||
&.after .diffLabel {
|
||||
color: var(--accent-forest);
|
||||
}
|
||||
}
|
||||
|
||||
.diffBlockHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.diffLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.diffJson {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
background: var(--l2-background);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 5px 7px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
max-height: 140px;
|
||||
color: var(--l2-foreground);
|
||||
@include scrollbar(0.4rem);
|
||||
|
||||
// Wrap long lines instead of horizontal scrolling. Used in the
|
||||
// expanded modal when the user toggles the "Wrap text" button.
|
||||
&.wrapped {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.diffModalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diffToolbarRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diffModalSummary {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.ok {
|
||||
color: var(--accent-forest);
|
||||
}
|
||||
&.no {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogSubtitle,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ApprovalEventDTODiff,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
Check,
|
||||
Columns2,
|
||||
Copy,
|
||||
List,
|
||||
Maximize2,
|
||||
Shield,
|
||||
WrapText,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import styles from './ApprovalCard.module.scss';
|
||||
|
||||
interface ApprovalCardProps {
|
||||
conversationId: string;
|
||||
approval: ApprovalEventDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [diffExpanded, setDiffExpanded] = useState(false);
|
||||
const [wrapText, setWrapText] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<DiffViewMode>('split');
|
||||
|
||||
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={cx(styles.card, styles.decided)}>
|
||||
<Check size={13} className={cx(styles.statusIcon, styles.ok)} />
|
||||
<span className={styles.statusText}>Approved — resuming…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (decided === 'rejected') {
|
||||
return (
|
||||
<div className={cx(styles.card, styles.decided)}>
|
||||
<X size={13} className={cx(styles.statusIcon, styles.no)} />
|
||||
<span className={styles.statusText}>Rejected.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Shield size={13} className={styles.shieldIcon} />
|
||||
<span className={styles.headerLabel}>Action requires approval</span>
|
||||
<span className={styles.resourceBadge}>
|
||||
{approval.actionType} · {approval.resourceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={styles.summary}>{approval.summary}</p>
|
||||
|
||||
{approval.diff && (
|
||||
<div className={styles.diffSection}>
|
||||
<div className={styles.diffHeader}>
|
||||
<span className={styles.diffHeaderLabel}>Diff</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={(): void => setDiffExpanded(true)}
|
||||
title="Expand diff"
|
||||
aria-label="Expand diff"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<DiffView diff={approval.diff} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={diffExpanded} onOpenChange={setDiffExpanded}>
|
||||
<DialogContent
|
||||
className={styles.diffDialog}
|
||||
style={{ width: '80vw', maxWidth: '80vw', height: '70vh' }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approval diff</DialogTitle>
|
||||
<DialogSubtitle>
|
||||
{approval.actionType} · {approval.resourceType}
|
||||
</DialogSubtitle>
|
||||
</DialogHeader>
|
||||
<div className={styles.diffModalBody}>
|
||||
<p className={styles.diffModalSummary}>{approval.summary}</p>
|
||||
<div className={styles.diffToolbarRow}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={(next): void => {
|
||||
// Radix `single` group can emit '' when the active item
|
||||
// is clicked again — preserve the current mode.
|
||||
if (next === 'split' || next === 'unified') {
|
||||
setViewMode(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="split" aria-label="Split view">
|
||||
<Columns2 size={12} />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="unified" aria-label="Unified view">
|
||||
<List size={12} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
size="sm"
|
||||
value={wrapText ? ['wrap'] : []}
|
||||
onChange={(next): void => setWrapText(next.includes('wrap'))}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="wrap"
|
||||
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
|
||||
>
|
||||
<WrapText size={12} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{approval.diff && (
|
||||
<DiffView
|
||||
diff={approval.diff}
|
||||
expanded
|
||||
wrapText={wrapText}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogCloseButton onClick={(): void => setDiffExpanded(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className={styles.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>
|
||||
);
|
||||
}
|
||||
|
||||
type DiffViewMode = 'split' | 'unified';
|
||||
|
||||
interface DiffViewProps {
|
||||
diff: ApprovalEventDTODiff;
|
||||
expanded?: boolean;
|
||||
/** When true, long lines wrap instead of horizontally scrolling. */
|
||||
wrapText?: boolean;
|
||||
/** Side-by-side ('split') vs single-column ('unified'). Only honored when expanded. */
|
||||
viewMode?: DiffViewMode;
|
||||
}
|
||||
|
||||
function DiffView({
|
||||
diff,
|
||||
expanded = false,
|
||||
wrapText = false,
|
||||
viewMode = 'split',
|
||||
}: DiffViewProps): JSX.Element {
|
||||
const beforeText =
|
||||
diff.before !== undefined ? JSON.stringify(diff.before, null, 2) : '';
|
||||
const afterText =
|
||||
diff.after !== undefined ? JSON.stringify(diff.after, null, 2) : '';
|
||||
|
||||
// In the inline (collapsed) preview keep the original two-pane layout
|
||||
// without diff highlighting — diffing is opt-in via the expanded modal.
|
||||
if (!expanded) {
|
||||
const jsonClass = cx(styles.diffJson, { [styles.wrapped]: wrapText });
|
||||
return (
|
||||
<div className={styles.diff}>
|
||||
{diff.before !== undefined && (
|
||||
<div className={cx(styles.diffBlock, styles.before)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>Before</span>
|
||||
</div>
|
||||
<pre className={jsonClass}>{beforeText}</pre>
|
||||
</div>
|
||||
)}
|
||||
{diff.after !== undefined && (
|
||||
<div className={cx(styles.diffBlock, styles.after)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>After</span>
|
||||
</div>
|
||||
<pre className={jsonClass}>{afterText}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = computeLineDiff(beforeText, afterText);
|
||||
|
||||
if (viewMode === 'unified') {
|
||||
// Build the same +/-/space-prefixed text that's on screen so Copy
|
||||
// gives the user exactly what they see.
|
||||
const unifiedText = lines
|
||||
.map((line) => `${prefixFor(line.op)} ${line.text}`)
|
||||
.join('\n');
|
||||
return (
|
||||
<div className={cx(styles.diff, styles.expanded, styles.unified)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>Diff</span>
|
||||
<div className={styles.diffHeaderActions}>
|
||||
<CopyButton text={unifiedText} label="diff" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
|
||||
{lines.map((line, idx) => (
|
||||
<DiffLine
|
||||
// stable enough — input strings are immutable for the view's lifetime
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={idx}
|
||||
op={line.op}
|
||||
text={line.text}
|
||||
prefix={prefixFor(line.op)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split view: align side-by-side using the LCS result. `equal` lines
|
||||
// appear on both sides; `remove` only on the left, `add` only on the
|
||||
// right (with an empty placeholder on the missing side so rows stay
|
||||
// aligned vertically).
|
||||
return (
|
||||
<div className={cx(styles.diff, styles.expanded)}>
|
||||
<div className={cx(styles.diffBlock, styles.before)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>Before</span>
|
||||
<CopyButton text={beforeText} label="before" />
|
||||
</div>
|
||||
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
|
||||
{lines.map((line, idx) => {
|
||||
const op = line.op === 'add' ? 'placeholder' : line.op;
|
||||
const text = line.op === 'add' ? '' : line.text;
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DiffLine key={idx} op={op} text={text} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(styles.diffBlock, styles.after)}>
|
||||
<div className={styles.diffBlockHeader}>
|
||||
<span className={styles.diffLabel}>After</span>
|
||||
<CopyButton text={afterText} label="after" />
|
||||
</div>
|
||||
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
|
||||
{lines.map((line, idx) => {
|
||||
const op = line.op === 'remove' ? 'placeholder' : line.op;
|
||||
const text = line.op === 'remove' ? '' : line.text;
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DiffLine key={idx} op={op} text={text} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line diff — small LCS-based implementation. Avoids pulling in `diff`
|
||||
// since the inputs are JSON.stringify output (line-oriented, typically
|
||||
// well under a few hundred lines for resource diffs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LineOp = 'equal' | 'add' | 'remove';
|
||||
type RenderOp = LineOp | 'placeholder';
|
||||
interface DiffLineEntry {
|
||||
op: LineOp;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function computeLineDiff(before: string, after: string): DiffLineEntry[] {
|
||||
if (before === after) {
|
||||
return splitLines(before).map((text) => ({ op: 'equal', text }));
|
||||
}
|
||||
const a = splitLines(before);
|
||||
const b = splitLines(after);
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
|
||||
// dp[i][j] = length of LCS between a[0..i] and b[0..j]
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
new Array<number>(n + 1).fill(0),
|
||||
);
|
||||
for (let i = 1; i <= m; i += 1) {
|
||||
for (let j = 1; j <= n; j += 1) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack to produce the diff
|
||||
const result: DiffLineEntry[] = [];
|
||||
let i = m;
|
||||
let j = n;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
result.push({ op: 'equal', text: a[i - 1] });
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
||||
result.push({ op: 'remove', text: a[i - 1] });
|
||||
i -= 1;
|
||||
} else {
|
||||
result.push({ op: 'add', text: b[j - 1] });
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
while (i > 0) {
|
||||
result.push({ op: 'remove', text: a[i - 1] });
|
||||
i -= 1;
|
||||
}
|
||||
while (j > 0) {
|
||||
result.push({ op: 'add', text: b[j - 1] });
|
||||
j -= 1;
|
||||
}
|
||||
result.reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === '') {
|
||||
return [];
|
||||
}
|
||||
return text.split('\n');
|
||||
}
|
||||
|
||||
function prefixFor(op: LineOp): string {
|
||||
if (op === 'add') {
|
||||
return '+';
|
||||
}
|
||||
if (op === 'remove') {
|
||||
return '-';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
interface DiffLineProps {
|
||||
op: RenderOp;
|
||||
text: string;
|
||||
/** Optional gutter prefix used in unified view (`+` / `-` / ` `). */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
function DiffLine({ op, text, prefix }: DiffLineProps): JSX.Element {
|
||||
const cls = cx(styles.diffLine, {
|
||||
[styles.diffLineAdd]: op === 'add',
|
||||
[styles.diffLineRemove]: op === 'remove',
|
||||
[styles.diffLinePlaceholder]: op === 'placeholder',
|
||||
});
|
||||
return (
|
||||
<div className={cls}>
|
||||
{prefix !== undefined && (
|
||||
<span className={styles.diffGutter} aria-hidden="true">
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.diffLineText}>{text || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function CopyButton({ text, label }: CopyButtonProps): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
// Track the timeout so an unmount mid-flight doesn't try to setState on
|
||||
// a dead component (and so a rapid re-click resets the 1.5s window).
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = (): void => {
|
||||
copyToClipboard(text);
|
||||
setCopied(true);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopy}
|
||||
title={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ApprovalCard';
|
||||
export { default } from './ApprovalCard';
|
||||
@@ -0,0 +1,462 @@
|
||||
@use '../../_scrollbar' as *;
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--l1-background);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
transition: border-color 0.15s;
|
||||
position: relative;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.contextTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.contextTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 4px 6px 4px 8px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
button {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
// `auto` chips are derived from the URL (current page) — visually
|
||||
// distinguished by a dashed border + slightly muted text so the user
|
||||
// can tell them apart from explicit @-mentions. Tighter padding /
|
||||
// font-size keeps them visually subordinate to user `@`-picks.
|
||||
&.auto {
|
||||
border-style: dashed;
|
||||
color: var(--l2-foreground);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px 2px 6px;
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.contextTagContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 220px;
|
||||
|
||||
.contextTagCategory {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contextTagLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.contextTagCategory {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contextTagLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contextTagRemove {
|
||||
flex-shrink: 0;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.attachmentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--l3-background);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 2px 6px 2px 8px;
|
||||
color: var(--l2-foreground);
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachmentRemove {
|
||||
flex-shrink: 0;
|
||||
padding: 0 !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.leftActions,
|
||||
.rightActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachBtn {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.contextBtn {
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
border-style: dashed !important;
|
||||
padding-inline: 12px !important;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
@include scrollbar(0.2rem);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.charWarning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-sienna);
|
||||
background: color-mix(in srgb, var(--accent-sienna), transparent 90%);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-sienna), transparent 65%);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
&.stop {
|
||||
background: var(--accent-cherry) !important;
|
||||
border-color: var(--accent-cherry) !important;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextPopover {
|
||||
width: 480px !important;
|
||||
max-width: min(92vw, 480px);
|
||||
margin-left: 16px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.3);
|
||||
padding: 8px;
|
||||
// Clip horizontal overflow so long entity titles can't poke past the
|
||||
// popover's right edge. Vertical overflow is handled inside
|
||||
// `.contextPopoverEntities`.
|
||||
overflow-x: hidden;
|
||||
|
||||
--popover-padding: 0;
|
||||
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.contextPopoverContent {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
min-height: 250px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contextPopoverCategories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.contextPopoverCategoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
|
||||
border-radius: var(--radius-2);
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--accent-primary), transparent 86%);
|
||||
color: var(--l1-foreground);
|
||||
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);
|
||||
}
|
||||
}
|
||||
|
||||
.contextPopoverRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
// Match the previous fixed entity-list height so the inner search +
|
||||
// scrolling list have a definite container to size against.
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.contextPopoverSearch {
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.contextPopoverSearchInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contextPopoverEntities {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px;
|
||||
min-width: 0;
|
||||
@include scrollbar(0.2rem);
|
||||
}
|
||||
|
||||
.contextPopoverEntityItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
// Required for the inner span's `text-overflow: ellipsis` to engage —
|
||||
// flex items default to `min-width: auto` (intrinsic width) and would
|
||||
// otherwise grow past their parent's width to fit long titles.
|
||||
min-width: 0;
|
||||
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--l2-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: color-mix(in srgb, var(--accent-primary), transparent 86%);
|
||||
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);
|
||||
|
||||
span {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextPopoverEntityItemText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contextPopoverEmpty {
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.micBtn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.micRecording {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--l2-background);
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.micDiscard,
|
||||
.micStop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.micDiscard {
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
transition: color 0.12s;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.micWaves {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-foreground);
|
||||
animation: voiceWave 0.9s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
&:nth-child(6) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(7) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(8) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes voiceWave {
|
||||
0%,
|
||||
100% {
|
||||
height: 2px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
height: 12px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.micStop {
|
||||
background: var(--accent-cherry);
|
||||
color: var(--accent-cherry-foreground);
|
||||
transition: opacity 0.12s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import cx from 'classnames';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { UploadFile } from 'antd';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
import type { SuccessResponseV2 } from 'types/api';
|
||||
import type { Dashboard } from 'types/api/dashboard/getAll';
|
||||
// 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-assistant/chat';
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
Mic,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './ChatInput.module.scss';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (
|
||||
text: string,
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
) => void;
|
||||
onCancel?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
/**
|
||||
* URL-derived `source: 'auto'` contexts representing the page the user is
|
||||
* currently looking at. Rendered as chips alongside the user's `@`-mention
|
||||
* picks and merged into the outgoing `contexts` array.
|
||||
*/
|
||||
autoContexts?: MessageContext[];
|
||||
/**
|
||||
* Called when the user dismisses an auto-context chip. The parent owns
|
||||
* the dismissed set and is responsible for filtering the next render's
|
||||
* `autoContexts` to exclude the key.
|
||||
*/
|
||||
onDismissAutoContext?: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Stable identity for an auto-context entry — used as React key + dismissal id. */
|
||||
export function autoContextKey(ctx: MessageContext): string {
|
||||
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
|
||||
return `auto:${ctx.type}:${ctx.resourceId ?? ''}:${page ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly label for an auto-derived context chip. We don't fetch resource
|
||||
* names from the URL alone, so we lean on the page identity that already
|
||||
* lives in `metadata.page`, falling back to the resource type.
|
||||
*/
|
||||
function autoContextLabel(ctx: MessageContext): string {
|
||||
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
|
||||
switch (page) {
|
||||
case 'dashboard_detail':
|
||||
return 'Current dashboard';
|
||||
case 'panel_edit':
|
||||
return 'Editing panel';
|
||||
case 'panel_fullscreen':
|
||||
return 'Panel (fullscreen)';
|
||||
case 'dashboard_list':
|
||||
return 'Dashboards';
|
||||
case 'alert_edit':
|
||||
return 'Editing alert';
|
||||
case 'alert_new':
|
||||
return 'New alert';
|
||||
case 'alerts_triggered':
|
||||
return 'Triggered alerts';
|
||||
case 'alert_list':
|
||||
return 'Alerts';
|
||||
case 'service_detail':
|
||||
return 'Current service';
|
||||
case 'services_list':
|
||||
return 'Services';
|
||||
case 'logs_explorer':
|
||||
return 'Logs explorer';
|
||||
case 'log_detail':
|
||||
return 'Log details';
|
||||
case 'traces_explorer':
|
||||
return 'Traces explorer';
|
||||
case 'trace_detail':
|
||||
return 'Trace details';
|
||||
case 'metrics_explorer':
|
||||
return 'Metrics explorer';
|
||||
default:
|
||||
return ctx.type;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capitalised category badge text — e.g. "Dashboard", "Logs explorer". */
|
||||
function autoContextCategory(ctx: MessageContext): string {
|
||||
switch (ctx.type) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard';
|
||||
case 'alert':
|
||||
return 'Alert';
|
||||
case 'service':
|
||||
return 'Service';
|
||||
case 'logs_explorer':
|
||||
return 'Logs';
|
||||
case 'traces_explorer':
|
||||
return 'Traces';
|
||||
case 'metrics_explorer':
|
||||
return 'Metrics';
|
||||
case 'saved_view':
|
||||
return 'Saved view';
|
||||
default:
|
||||
return ctx.type;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_INPUT_LENGTH = 20000;
|
||||
const WARNING_THRESHOLD = 15000;
|
||||
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
|
||||
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] 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,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ContextEntityItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CONTEXT_CATEGORY_ICONS = {
|
||||
Dashboards: LayoutDashboard,
|
||||
Alerts: Bell,
|
||||
Services: ShieldCheck,
|
||||
} satisfies Record<ContextCategory, unknown>;
|
||||
|
||||
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,
|
||||
autoContexts,
|
||||
onDismissAutoContext,
|
||||
}: 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 [pickerSearchQuery, setPickerSearchQuery] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// When the picker was opened by typing `@` in the textarea, this holds the
|
||||
// span of `@<query>` (start / end indices into `text`). Used both for live
|
||||
// filtering of the entity list and for splicing the trigger out of the
|
||||
// text once the user picks an item. `null` when the picker is opened via
|
||||
// the "Add Context" button (no trigger to strip, no query to filter).
|
||||
const [mentionRange, setMentionRange] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
} | null>(null);
|
||||
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);
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
const query = beforeCaret.slice(atIndex + 1);
|
||||
if (/\s/.test(query)) {
|
||||
setIsContextPickerOpen(false);
|
||||
setMentionRange(null);
|
||||
return;
|
||||
}
|
||||
setIsContextPickerOpen(true);
|
||||
setMentionRange({ start: atIndex, end: caret });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleContextSelection = useCallback(
|
||||
(category: ContextCategory, entityId: string, contextValue: string) => {
|
||||
const wasSelected = selectedContexts.some(
|
||||
(item) => item.category === category && item.entityId === entityId,
|
||||
);
|
||||
|
||||
setSelectedContexts((prev) => {
|
||||
if (wasSelected) {
|
||||
return prev.filter(
|
||||
(item) => !(item.category === category && item.entityId === entityId),
|
||||
);
|
||||
}
|
||||
return [...prev, { category, entityId, value: contextValue }];
|
||||
});
|
||||
|
||||
// When the user picks an item via the `@` trigger, splice the
|
||||
// `@<query>` span out of the textarea so their prose stays clean.
|
||||
// Skip on remove (no trigger to strip) and when the picker was
|
||||
// opened from the "Add Context" button (no mention range tracked).
|
||||
if (!wasSelected && mentionRange) {
|
||||
const next =
|
||||
text.slice(0, mentionRange.start) + text.slice(mentionRange.end);
|
||||
setText(next);
|
||||
committedTextRef.current = next;
|
||||
setMentionRange(null);
|
||||
}
|
||||
},
|
||||
[mentionRange, selectedContexts, text],
|
||||
);
|
||||
|
||||
// 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 userContexts = selectedContexts
|
||||
.map(toMessageContext)
|
||||
.filter((context): context is MessageContext => context !== null);
|
||||
// Auto contexts come first so the agent reads "current page" before
|
||||
// any explicit @-mentions when both are present.
|
||||
const contexts = [...(autoContexts ?? []), ...userContexts];
|
||||
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, autoContexts, 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,
|
||||
permission: micPermission,
|
||||
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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const showMic = isSupported && micPermission !== 'denied';
|
||||
|
||||
// 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]);
|
||||
|
||||
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
|
||||
// Hold the combo to record; release Space to submit. We track which key
|
||||
// triggered PTT in a ref so a late-released modifier (Cmd/Shift) doesn't
|
||||
// accidentally stop the session. Auto-repeat is suppressed via a
|
||||
// "session active" ref so a held key only calls `start()` once.
|
||||
const pttActiveRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isSupported || micPermission === 'denied') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const isComboKey =
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.shiftKey &&
|
||||
(e.code === 'Space' || e.key === ' ');
|
||||
if (!isComboKey || disabled || isStreaming) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (pttActiveRef.current) {
|
||||
return; // ignore auto-repeat
|
||||
}
|
||||
pttActiveRef.current = true;
|
||||
start();
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent): void => {
|
||||
if (!pttActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
// End on the *first* released key in the combo. macOS browsers
|
||||
// frequently swallow keyup of regular keys (incl. Space) while
|
||||
// Cmd is held, so we can't rely on Space-up alone — releasing
|
||||
// Cmd/Ctrl/Shift must also stop the session.
|
||||
const isComboKey =
|
||||
e.code === 'Space' ||
|
||||
e.key === ' ' ||
|
||||
e.key === 'Meta' ||
|
||||
e.key === 'Control' ||
|
||||
e.key === 'Shift';
|
||||
if (!isComboKey) {
|
||||
return;
|
||||
}
|
||||
pttActiveRef.current = false;
|
||||
e.preventDefault();
|
||||
void handleStopAndSend();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
isSupported,
|
||||
micPermission,
|
||||
disabled,
|
||||
isStreaming,
|
||||
start,
|
||||
handleStopAndSend,
|
||||
]);
|
||||
|
||||
// Each list hook fetches only when its picker tab is actively shown,
|
||||
// AND treats already-cached data as never stale (`staleTime: Infinity`)
|
||||
// so an open with a populated cache doesn't trigger a background
|
||||
// refetch. Net effect: assistant-driven fetches happen exactly once
|
||||
// per resource list per session, on the first cache miss. Gating on
|
||||
// `isContextPickerOpen` (not just `activeContextCategory`) is important
|
||||
// — the latter defaults to 'Dashboards' on every mount, so without the
|
||||
// picker-open check the dashboards list refetches on every new
|
||||
// conversation.
|
||||
const {
|
||||
data: dashboardsResponse,
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
} = useGetAllDashboard({
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const {
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: servicesResponse,
|
||||
isLoading: isServicesLoading,
|
||||
isFetching: isServicesFetching,
|
||||
isError: isServicesError,
|
||||
} = useQueryService({
|
||||
minTime: servicesTimeRange.startTime * 1e6,
|
||||
maxTime: servicesTimeRange.endTime * 1e6,
|
||||
selectedTime,
|
||||
selectedTags: [],
|
||||
options: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Services',
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves an auto-context to a human label: dashboard title, alert name,
|
||||
* service name (the service `resourceId` IS the name), or a generic page
|
||||
* label as fallback while the lookup data is still loading.
|
||||
*
|
||||
* Reads passively from the React Query cache via `getQueryData` —
|
||||
* never triggers a fetch. If the cache is empty (e.g. assistant opened
|
||||
* on a page that hasn't loaded the resource list yet), the chip falls
|
||||
* back to a generic label and resolves once the cache fills via the
|
||||
* picker or another page.
|
||||
*/
|
||||
const resolveAutoContextName = useCallback(
|
||||
(ctx: MessageContext): string => {
|
||||
if (ctx.type === 'service' && ctx.resourceId) {
|
||||
return ctx.resourceId;
|
||||
}
|
||||
if (ctx.type === 'dashboard' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
|
||||
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
|
||||
);
|
||||
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash?.data.title) {
|
||||
return dash.data.title;
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
}
|
||||
const page = (
|
||||
ctx.metadata as { page?: string; traceId?: string } | null | undefined
|
||||
)?.page;
|
||||
if (page === 'trace_detail') {
|
||||
const traceId = (ctx.metadata as { traceId?: string } | null | undefined)
|
||||
?.traceId;
|
||||
if (traceId) {
|
||||
return `${traceId.slice(0, 8)}…`;
|
||||
}
|
||||
}
|
||||
return autoContextLabel(ctx);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
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,
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
const contextCategoryStateByCategory: Record<
|
||||
ContextCategory,
|
||||
{ isLoading: boolean; isError: boolean }
|
||||
> = {
|
||||
Dashboards: {
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
},
|
||||
Alerts: {
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
},
|
||||
Services: {
|
||||
isLoading: isServicesLoading || isServicesFetching,
|
||||
isError: isServicesError,
|
||||
},
|
||||
};
|
||||
|
||||
// Type-ahead filter against the `@<query>` typed in the textarea. When
|
||||
// the picker was opened from the "Add Context" button there's no
|
||||
// mention query, so fall back to the in-popover search input.
|
||||
const mentionQuery = mentionRange
|
||||
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
|
||||
: '';
|
||||
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
|
||||
const filteredContextOptions = activeQuery
|
||||
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
|
||||
entity.value.toLowerCase().includes(activeQuery),
|
||||
)
|
||||
: contextEntitiesByCategory[activeContextCategory];
|
||||
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
|
||||
contextCategoryStateByCategory[activeContextCategory];
|
||||
const currentLength = text.length;
|
||||
const showTextWarning = currentLength >= WARNING_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className={styles.input} ref={inputRootRef}>
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{pendingFiles.map((f) => (
|
||||
<div key={f.uid} className={styles.attachmentChip}>
|
||||
<span className={styles.attachmentName}>{f.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={styles.attachmentRemove}
|
||||
onClick={(): void => removeFile(f.uid)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedContexts.length > 0 ||
|
||||
(autoContexts && autoContexts.length > 0)) && (
|
||||
<div className={styles.contextTags}>
|
||||
{autoContexts?.map((ctx) => {
|
||||
const key = autoContextKey(ctx);
|
||||
const label = resolveAutoContextName(ctx);
|
||||
const category = autoContextCategory(ctx);
|
||||
return (
|
||||
<div key={key} className={cx(styles.contextTag, styles.auto)}>
|
||||
<div className={styles.contextTagContent}>
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
className={styles.contextTagCategory}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
<span className={styles.contextTagLabel}>{label}</span>
|
||||
</div>
|
||||
{onDismissAutoContext && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.contextTagRemove}
|
||||
onClick={(): void => onDismissAutoContext(key)}
|
||||
aria-label={`Remove ${category}: ${label} context`}
|
||||
prefix={<X size={10} />}
|
||||
></Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedContexts.map((contextItem) => (
|
||||
<div
|
||||
key={`${contextItem.category}:${contextItem.entityId}`}
|
||||
className={styles.contextTag}
|
||||
>
|
||||
<div className={styles.contextTagContent}>
|
||||
<Badge
|
||||
color="primary"
|
||||
variant="outline"
|
||||
className={styles.contextTagCategory}
|
||||
>
|
||||
{contextItem.category}
|
||||
</Badge>
|
||||
<span className={styles.contextTagLabel}>{contextItem.value}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={styles.contextTagRemove}
|
||||
onClick={(): void =>
|
||||
removeContext(contextItem.category, contextItem.entityId)
|
||||
}
|
||||
aria-label={`Remove ${contextItem.category}: ${contextItem.value} context`}
|
||||
prefix={<X size={10} />}
|
||||
></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.composer}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={styles.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={styles.charWarning} role="status">
|
||||
<TriangleAlert size={12} />
|
||||
<span>
|
||||
{currentLength}/{MAX_INPUT_LENGTH} characters. Limit is {MAX_INPUT_LENGTH}
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.leftActions}>
|
||||
<Popover
|
||||
open={isContextPickerOpen}
|
||||
onOpenChange={(open): void => {
|
||||
setIsContextPickerOpen(open);
|
||||
if (!open) {
|
||||
setActiveContextCategory('Dashboards');
|
||||
setPickerSearchQuery('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={(): void => {
|
||||
setActiveContextCategory('Dashboards');
|
||||
setPickerSearchQuery('');
|
||||
}}
|
||||
prefix={<Plus size={10} />}
|
||||
>
|
||||
Add Context
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={styles.contextPopover}
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className={styles.contextPopoverContent}>
|
||||
<div className={styles.contextPopoverCategories}>
|
||||
{CONTEXT_CATEGORIES.map((category) => {
|
||||
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
|
||||
const isActive = activeContextCategory === category;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={isActive}
|
||||
className={cx(styles.contextPopoverCategoryItem, {
|
||||
[styles.active]: isActive,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CategoryIcon size={13} />
|
||||
<span>{category}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.contextPopoverRight}>
|
||||
<div className={styles.contextPopoverSearch}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Search ${activeContextCategory.toLowerCase()}…`}
|
||||
className={styles.contextPopoverSearchInput}
|
||||
value={pickerSearchQuery}
|
||||
onChange={(e): void => setPickerSearchQuery(e.target.value)}
|
||||
prefix={<Search size={12} />}
|
||||
// Skip the picker's roving keyboard focus — typing here
|
||||
// shouldn't move category selection.
|
||||
onKeyDown={(e): void => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.contextPopoverEntities}>
|
||||
{isActiveContextLoading ? (
|
||||
<div className={styles.contextPopoverEmpty}>
|
||||
Loading {activeContextCategory.toLowerCase()}...
|
||||
</div>
|
||||
) : isActiveContextError ? (
|
||||
<div className={styles.contextPopoverEmpty}>
|
||||
Failed to load {activeContextCategory.toLowerCase()}.
|
||||
</div>
|
||||
) : filteredContextOptions.length === 0 ? (
|
||||
<div className={styles.contextPopoverEmpty}>
|
||||
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={cx(styles.contextPopoverEntityItem, {
|
||||
[styles.selected]: isSelected,
|
||||
})}
|
||||
onClick={(): void =>
|
||||
toggleContextSelection(
|
||||
activeContextCategory,
|
||||
option.id,
|
||||
option.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={styles.contextPopoverEntityItemText}>
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className={styles.rightActions}>
|
||||
{showMic &&
|
||||
(isListening ? (
|
||||
<div className={styles.micRecording}>
|
||||
<div
|
||||
className={cx(styles.micDiscard, styles.secondary)}
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
>
|
||||
<X size={12} />
|
||||
</div>
|
||||
<span className={styles.micWaves} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<div
|
||||
className={cx(styles.micStop, styles.destructive)}
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
>
|
||||
<Square size={9} fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title="Voice input">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={start}
|
||||
disabled={disabled}
|
||||
aria-label="Start voice input"
|
||||
className={styles.micBtn}
|
||||
>
|
||||
<Mic size={14} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
|
||||
{isStreaming && onCancel ? (
|
||||
<TooltipSimple title="Stop generating">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
color="destructive"
|
||||
onClick={onCancel}
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={10} fill="currentColor" strokeWidth={0} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ChatInput';
|
||||
export { default } from './ChatInput';
|
||||
@@ -0,0 +1,133 @@
|
||||
.clarification {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 10px 12px;
|
||||
background: var(--l2-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
&.submitted {
|
||||
border-color: var(--l2-border);
|
||||
background: transparent;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.headerLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--accent-cherry);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Constrain the Radix-based SelectContent popover so it never grows wider
|
||||
// than the trigger button. `--radix-select-trigger-width` is set by Radix
|
||||
// at the popper layer when `position: 'popper'` (the default).
|
||||
.selectContent {
|
||||
width: var(--radix-select-trigger-width);
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
.radioGroup,
|
||||
.checkboxGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.radioLabel,
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio,
|
||||
.checkbox {
|
||||
accent-color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type {
|
||||
ClarificationEventDTO,
|
||||
ClarificationFieldEventDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { CircleHelp, Send, X } from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import styles from './ClarificationForm.module.scss';
|
||||
|
||||
/** Sentinel emitted by the select dropdown when the user picks the custom slot. */
|
||||
const CUSTOM_OPTION_SENTINEL = '__signoz_ai_custom__';
|
||||
/** User-facing label for the synthetic "type your own answer" option. */
|
||||
const CUSTOM_OPTION_LABEL = 'Other (type your own)';
|
||||
|
||||
interface ClarificationFormProps {
|
||||
conversationId: string;
|
||||
clarification: ClarificationEventDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 fields = clarification.fields ?? [];
|
||||
const initialAnswers = Object.fromEntries(
|
||||
fields.map((f) => [f.id, initialAnswerFor(f)]),
|
||||
);
|
||||
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={cx(styles.clarification, styles.submitted)}>
|
||||
<Send size={13} className={styles.icon} />
|
||||
<span className={styles.statusText}>Answers submitted — resuming…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<div className={cx(styles.clarification, styles.submitted)}>
|
||||
<X size={13} className={styles.icon} />
|
||||
<span className={styles.statusText}>Request cancelled.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.clarification}>
|
||||
<div className={styles.header}>
|
||||
<CircleHelp size={13} className={styles.headerIcon} />
|
||||
<span className={styles.headerLabel}>A few details needed</span>
|
||||
</div>
|
||||
|
||||
<p className={styles.message}>{clarification.message}</p>
|
||||
|
||||
<div className={styles.fields}>
|
||||
{fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={answers[field.id]}
|
||||
onChange={(val): void => setField(field.id, val)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.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 — covers every variant of ClarificationFieldTypeDTO:
|
||||
// text, number, select, multi_select, boolean.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-type seed value. The DTO's `default` is `string | string[] | null`,
|
||||
* which doesn't fit boolean fields cleanly — we coerce 'true'/'false' strings
|
||||
* for them, fall back to `[]` for multi_select, and the raw string otherwise.
|
||||
*/
|
||||
function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
|
||||
const raw = f.default;
|
||||
if (f.type === ClarificationFieldTypeDTO.boolean) {
|
||||
// `default` is typed string | string[] | null; backend sends
|
||||
// 'true'/'false' as strings for boolean fields.
|
||||
return raw === 'true';
|
||||
}
|
||||
if (f.type === ClarificationFieldTypeDTO.multi_select) {
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
return raw ?? '';
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
field: ClarificationFieldEventDTO;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
const { id, type, label, required, options, allowCustom } = field;
|
||||
|
||||
// Local UI state for the synthetic "custom" option on select /
|
||||
// multi_select fields with `allowCustom`. The free-text input only renders
|
||||
// when this is true; the typed value is what's actually sent up via
|
||||
// `onChange` (never the sentinel / "Other" label).
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
|
||||
// Render the select if the field has options OR if the server marked it
|
||||
// `allowCustom` (in which case the dropdown still appears with just the
|
||||
// "Other (type your own)" entry — a plain `options: null` would
|
||||
// otherwise fall through to the bare text-input renderer).
|
||||
if (type === ClarificationFieldTypeDTO.select && (options || allowCustom)) {
|
||||
const handleSelectChange = (next: string | string[]): void => {
|
||||
// `multiple` is off → callback receives a single string. The wider
|
||||
// `string | string[]` typing comes from the shared Select root.
|
||||
const picked = Array.isArray(next) ? (next[0] ?? '') : next;
|
||||
if (picked === CUSTOM_OPTION_SENTINEL) {
|
||||
setIsCustom(true);
|
||||
onChange(customValue);
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
onChange(picked);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</label>
|
||||
<Select
|
||||
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
|
||||
onChange={handleSelectChange}
|
||||
>
|
||||
<SelectTrigger id={id} placeholder="Select…" />
|
||||
{/* Pin the dropdown width to the trigger via Radix's
|
||||
`--radix-select-trigger-width`; otherwise the popover
|
||||
sizes to its widest item and looks misaligned. */}
|
||||
<SelectContent className={styles.selectContent}>
|
||||
{options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
{allowCustom && (
|
||||
<SelectItem value={CUSTOM_OPTION_SENTINEL}>
|
||||
{CUSTOM_OPTION_LABEL}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCustom && (
|
||||
<Input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Enter a custom value"
|
||||
value={customValue}
|
||||
onChange={(e): void => {
|
||||
setCustomValue(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean — single yes/no checkbox. The label sits inside the checkbox
|
||||
// so the click target covers both, matching how multi_select rows render.
|
||||
if (type === ClarificationFieldTypeDTO.boolean) {
|
||||
const checked = value === true;
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Checkbox
|
||||
className={styles.checkboxLabel}
|
||||
value={checked}
|
||||
onChange={(): void => onChange(!checked)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Same fallback logic as the select branch — render the checkbox group
|
||||
// when there are options OR when the field is `allowCustom` only.
|
||||
if (
|
||||
type === ClarificationFieldTypeDTO.multi_select &&
|
||||
(options || allowCustom)
|
||||
) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
// Anything in the value array that isn't one of the predefined options
|
||||
// is treated as a custom entry — we keep at most one custom entry,
|
||||
// driven by the local `customValue` + `isCustom` state below.
|
||||
const regularSelected = selected.filter((v) => options?.includes(v));
|
||||
|
||||
const toggleRegular = (opt: string): void => {
|
||||
const nextRegular = regularSelected.includes(opt)
|
||||
? regularSelected.filter((v) => v !== opt)
|
||||
: [...regularSelected, opt];
|
||||
onChange(
|
||||
isCustom && customValue ? [...nextRegular, customValue] : nextRegular,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCustom = (): void => {
|
||||
if (isCustom) {
|
||||
setIsCustom(false);
|
||||
onChange(regularSelected);
|
||||
} else {
|
||||
setIsCustom(true);
|
||||
onChange(customValue ? [...regularSelected, customValue] : regularSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCustomValue = (next: string): void => {
|
||||
setCustomValue(next);
|
||||
if (isCustom) {
|
||||
onChange(next ? [...regularSelected, next] : regularSelected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</span>
|
||||
<div className={styles.checkboxGroup}>
|
||||
{options?.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt}
|
||||
className={styles.checkboxLabel}
|
||||
value={regularSelected.includes(opt)}
|
||||
onChange={(): void => toggleRegular(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Checkbox>
|
||||
))}
|
||||
{allowCustom && (
|
||||
<Checkbox
|
||||
className={styles.checkboxLabel}
|
||||
value={isCustom}
|
||||
onChange={toggleCustom}
|
||||
>
|
||||
{CUSTOM_OPTION_LABEL}
|
||||
</Checkbox>
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<Input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Enter a custom value"
|
||||
value={customValue}
|
||||
onChange={(e): void => updateCustomValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text / number (default)
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
type={type === 'number' ? 'number' : 'text'}
|
||||
className={styles.input}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void =>
|
||||
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
placeholder={label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ClarificationForm';
|
||||
export { default } from './ClarificationForm';
|
||||
@@ -0,0 +1,145 @@
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-2);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
// Driven below: hover and active reveal the action buttons.
|
||||
--actions-opacity: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
--actions-opacity: 1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--l2-background);
|
||||
--actions-opacity: 1;
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&.archived {
|
||||
opacity: 0.92;
|
||||
|
||||
.title {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 10px;
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--accent-primary);
|
||||
outline: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
opacity: var(--actions-opacity, 0);
|
||||
transition: opacity 0.12s;
|
||||
// Float over the right edge of the item so the title can keep using
|
||||
// the full width while the buttons are hidden — no layout shift +
|
||||
// no premature truncation. The `background` matches the hover/active
|
||||
// state so the buttons visually mask any title text underneath.
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 4px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--l2-background);
|
||||
padding: 1px 2px;
|
||||
border-radius: var(--radius-2);
|
||||
pointer-events: var(--actions-pointer, none);
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item.active {
|
||||
--actions-pointer: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 2px !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
|
||||
&.danger:hover {
|
||||
color: var(--accent-cherry) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact menu — narrower than the design-system default so the
|
||||
// content (Rename / Copy link / Archive) doesn't dwarf the row.
|
||||
.menu {
|
||||
min-width: 160px !important;
|
||||
width: 160px !important;
|
||||
}
|
||||
|
||||
// Shared sizing for every dropdown item so the menu reads compact —
|
||||
// matches the row's own 12px label scale.
|
||||
.menuItem {
|
||||
font-size: 12px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
// Amber treatment for the destructive-but-recoverable Archive action —
|
||||
// less alarming than red since the conversation can be restored later.
|
||||
// Targets both the label text and the leading icon (icons inherit color
|
||||
// via `currentColor`).
|
||||
.archiveItem {
|
||||
color: var(--accent-amber) !important;
|
||||
|
||||
svg {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
.restoreItem {
|
||||
color: var(--primary) !important;
|
||||
|
||||
svg {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import cx from 'classnames';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
EllipsisVertical,
|
||||
Link,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { Conversation } from '../../types';
|
||||
|
||||
import styles from './ConversationItem.module.scss';
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
onRestore: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: number): string {
|
||||
if (!Number.isFinite(ts)) {
|
||||
return '';
|
||||
}
|
||||
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,
|
||||
onArchive,
|
||||
onRestore,
|
||||
}: ConversationItemProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isArchived = Boolean(conversation.archived);
|
||||
const displayTitle = conversation.title ?? 'New conversation';
|
||||
const ts = conversation.updatedAt ?? conversation.createdAt;
|
||||
|
||||
const handleCopyLink = useCallback((): void => {
|
||||
// Prefer the server-side `threadId` so the link resolves for anyone
|
||||
// with access to this conversation. Fall back to the local id for
|
||||
// drafts that haven't synced yet — useful for the current session
|
||||
// even if the URL won't reload elsewhere.
|
||||
const id = conversation.threadId ?? conversation.id;
|
||||
const path = ROUTES.AI_ASSISTANT.replace(':conversationId', id);
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Conversation link copied to clipboard');
|
||||
}, [conversation.threadId, conversation.id, copyToClipboard]);
|
||||
|
||||
const startEditing = useCallback((): void => {
|
||||
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 itemClass = cx(styles.item, {
|
||||
[styles.active]: isActive,
|
||||
[styles.archived]: isArchived,
|
||||
});
|
||||
|
||||
// Dropdown items mirror the previous inline buttons but live in a single
|
||||
// trigger so the row stays compact. Archive/Restore swap based on the
|
||||
// archived state — same handler wiring as before.
|
||||
const baseItems = [
|
||||
{
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
icon: <Pencil size={12} />,
|
||||
className: styles.menuItem,
|
||||
onClick: (): void => startEditing(),
|
||||
},
|
||||
{
|
||||
key: 'copy-link',
|
||||
label: 'Copy link',
|
||||
icon: <Link size={12} />,
|
||||
className: styles.menuItem,
|
||||
onClick: handleCopyLink,
|
||||
},
|
||||
{ type: 'divider' as const, key: 'divider' },
|
||||
];
|
||||
const menuItems = isArchived
|
||||
? [
|
||||
...baseItems,
|
||||
{
|
||||
key: 'restore',
|
||||
label: 'Restore',
|
||||
icon: <ArchiveRestore size={12} />,
|
||||
className: cx(styles.menuItem, styles.restoreItem),
|
||||
onClick: (): void => onRestore(conversation.id),
|
||||
},
|
||||
]
|
||||
: [
|
||||
...baseItems,
|
||||
{
|
||||
key: 'archive',
|
||||
label: 'Archive',
|
||||
icon: <Archive size={12} />,
|
||||
className: cx(styles.menuItem, styles.archiveItem),
|
||||
onClick: (): void => onArchive(conversation.id),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={itemClass}
|
||||
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={styles.icon} />
|
||||
|
||||
<div className={styles.body}>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={editValue}
|
||||
onChange={(e): void => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={commitEdit}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
maxLength={80}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.title} title={displayTitle}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
<span className={styles.time}>{formatRelativeTime(ts)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div
|
||||
className={styles.actions}
|
||||
// Stop the row's onSelect from firing when the user opens the
|
||||
// menu or clicks an item — the menu lives in a portal so its
|
||||
// own clicks don't bubble, but the trigger button does.
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: menuItems }}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
className={styles.menu}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
color="none"
|
||||
className={styles.btn}
|
||||
aria-label="Conversation actions"
|
||||
prefix={<EllipsisVertical size={12} />}
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ConversationItem';
|
||||
export { default } from './ConversationItem';
|
||||
@@ -0,0 +1,84 @@
|
||||
.thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
|
||||
&.compact {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
// `width: 100%` (capped by per-role max-width below) forces the bubble
|
||||
// to fill its allotted slot rather than collapsing to the longest line —
|
||||
// otherwise the lines' percent widths cascade into a tiny bubble.
|
||||
width: 100%;
|
||||
border-radius: var(--radius-2);
|
||||
padding: 12px 14px;
|
||||
|
||||
&.user {
|
||||
// Narrower than the assistant bubble so the alternating chat-thread
|
||||
// asymmetry is preserved — but wider than the previous 80% so the
|
||||
// shimmer lines have room to read as a real-looking message.
|
||||
max-width: 75%;
|
||||
// Subtle primary tint so the right-side bubble reads as the user's
|
||||
// message without committing to the full saturated brand color.
|
||||
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||
border-bottom-right-radius: var(--radius-2);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
max-width: 95%;
|
||||
background: var(--l2-background);
|
||||
border-bottom-left-radius: var(--radius-2);
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 9px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
|
||||
|
||||
// Shimmer sweep — same pattern used by HistorySidebar's skeleton rows.
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--l1-foreground) 10%, transparent),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 1.15s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useVariant } from '../../VariantContext';
|
||||
|
||||
import styles from './ConversationSkeleton.module.scss';
|
||||
|
||||
/**
|
||||
* Each entry is one bubble in the placeholder thread:
|
||||
* role: who "sent" the bubble — drives left/right alignment + colour
|
||||
* lines: list of widths (as % of the bubble) for the shimmer lines inside
|
||||
*
|
||||
* Mixed widths and varying line counts produce something that scans as a real
|
||||
* back-and-forth conversation rather than a uniform grid.
|
||||
*/
|
||||
const ROWS: { role: 'user' | 'assistant'; lines: number[] }[] = [
|
||||
{ role: 'user', lines: [62] },
|
||||
{ role: 'assistant', lines: [85, 92, 70] },
|
||||
{ role: 'user', lines: [55, 40] },
|
||||
{ role: 'assistant', lines: [90, 78, 95, 60] },
|
||||
{ role: 'user', lines: [48] },
|
||||
{ role: 'assistant', lines: [80, 88] },
|
||||
];
|
||||
|
||||
/** Skeleton chat thread shown while a single conversation is being loaded. */
|
||||
export default function ConversationSkeleton(): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
|
||||
return (
|
||||
<div className={styles.thread} aria-busy aria-label="Loading conversation">
|
||||
{ROWS.map((row, idx) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={idx}
|
||||
className={cx(styles.message, styles[row.role], {
|
||||
[styles.compact]: isCompact,
|
||||
})}
|
||||
>
|
||||
<div className={cx(styles.bubble, styles[row.role])}>
|
||||
{row.lines.map((width, lineIdx) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={lineIdx}
|
||||
className={styles.line}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ConversationSkeleton';
|
||||
export { default } from './ConversationSkeleton';
|
||||
@@ -0,0 +1,136 @@
|
||||
@use '../../_scrollbar' as *;
|
||||
|
||||
.conversationsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
// Page variant: fixed-width left column.
|
||||
&.variantPage {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
// Panel variant: full-width overlay (replaces conversation view).
|
||||
&.variantPanel {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--l2-foreground);
|
||||
// Collapse the line-box to the glyph height so the loading dots
|
||||
// (centered against the line-box) line up with the cap-height of the
|
||||
// uppercase text instead of sitting visually low.
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
padding: 0px 8px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 6px 12px;
|
||||
@include scrollbar(0.25rem);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 20px 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.loadingDots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.loadingDot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
animation: historyLoadingDot 1.1s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes historyLoadingDot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.archived {
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { Conversation } from '../../types';
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import ConversationItem from '../ConversationItem';
|
||||
|
||||
import styles from './ConversationsList.module.scss';
|
||||
|
||||
interface ConversationsListProps {
|
||||
/** Called when a conversation is selected — lets the parent navigate if needed */
|
||||
onSelect?: (id: string) => void;
|
||||
onNewConversation?: () => void;
|
||||
showAddNewConversation?: boolean;
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-dot loading indicator. Sits inside the sidebar header so the
|
||||
* conversation list is never bumped down by a skeleton row when threads
|
||||
* load — visible signal of in-flight work without any layout shift.
|
||||
*/
|
||||
function HeaderLoadingDots(): JSX.Element {
|
||||
return (
|
||||
<span className={styles.loadingDots} role="status" aria-label="Loading">
|
||||
<span className={styles.loadingDot} />
|
||||
<span className={styles.loadingDot} />
|
||||
<span className={styles.loadingDot} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConversationsList({
|
||||
onSelect,
|
||||
onNewConversation,
|
||||
showAddNewConversation = false,
|
||||
}: ConversationsListProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
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 archiveConversation = useAIAssistantStore((s) => s.archiveConversation);
|
||||
const restoreConversation = useAIAssistantStore((s) => s.restoreConversation);
|
||||
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Fetch threads from backend on mount
|
||||
useEffect(() => {
|
||||
void fetchThreads();
|
||||
}, [fetchThreads]);
|
||||
|
||||
// Case-insensitive substring match against the conversation title.
|
||||
// Untitled conversations match the literal placeholder so users
|
||||
// searching for "new" can still find them.
|
||||
const trimmedQuery = searchQuery.trim().toLowerCase();
|
||||
const matchesQuery = (c: Conversation): boolean => {
|
||||
if (!trimmedQuery) {
|
||||
return true;
|
||||
}
|
||||
const title = (c.title ?? 'New conversation').toLowerCase();
|
||||
return title.includes(trimmedQuery);
|
||||
};
|
||||
|
||||
const sortedActive = useMemo(
|
||||
() =>
|
||||
Object.values(conversations)
|
||||
.filter((c) => !c.archived && matchesQuery(c))
|
||||
.sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[conversations, trimmedQuery],
|
||||
);
|
||||
|
||||
const sortedArchived = useMemo(
|
||||
() =>
|
||||
Object.values(conversations)
|
||||
.filter((c) => Boolean(c.archived) && c.threadId && matchesQuery(c))
|
||||
.sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[conversations, trimmedQuery],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => groupByDate(sortedActive), [sortedActive]);
|
||||
|
||||
const hasAnySidebarRows = groups.length > 0 || sortedArchived.length > 0;
|
||||
const isSearching = trimmedQuery.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);
|
||||
};
|
||||
|
||||
const variantClass =
|
||||
variant === 'page' ? styles.variantPage : styles.variantPanel;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.conversationsList, variantClass)}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.heading}>Conversations</span>
|
||||
{isLoadingThreads && <HeaderLoadingDots />}
|
||||
|
||||
{!isLoadingThreads && showAddNewConversation && (
|
||||
<TooltipSimple title="New conversation">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={onNewConversation}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.searchBar}>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
placeholder="Search conversations…"
|
||||
prefix={<Search size={12} />}
|
||||
className={styles.search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.list} aria-busy={isLoadingThreads}>
|
||||
{isLoadingThreads && (
|
||||
<span className={styles.srOnly} role="status">
|
||||
Loading conversations
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isLoadingThreads && !hasAnySidebarRows && (
|
||||
<p className={styles.empty}>
|
||||
{isSearching ? 'No matching conversations.' : 'No conversations yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={styles.group}>
|
||||
<span className={styles.groupLabel}>{label}</span>
|
||||
{items.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
onRename={renameConversation}
|
||||
onArchive={archiveConversation}
|
||||
onRestore={restoreConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedArchived.length > 0 && (
|
||||
<div className={cx(styles.group, styles.archived)}>
|
||||
<span className={styles.groupLabel}>Archived Conversations</span>
|
||||
{sortedArchived.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
onRename={renameConversation}
|
||||
onArchive={archiveConversation}
|
||||
onRestore={restoreConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ConversationsList';
|
||||
export { default } from './ConversationsList';
|
||||
@@ -0,0 +1,327 @@
|
||||
.message {
|
||||
display: flex;
|
||||
padding: 8px 16px;
|
||||
// CSS variable consumed by MessageFeedback to fade in on hover.
|
||||
--feedback-opacity: 0;
|
||||
|
||||
&:hover {
|
||||
--feedback-opacity: 1;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 80%;
|
||||
|
||||
&.compact {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.user & {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.assistant & {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
border-radius: var(--radius-2);
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 153.846% */
|
||||
letter-spacing: -0.065px;
|
||||
max-width: 100%;
|
||||
|
||||
.user & {
|
||||
background: var(--accent-primary);
|
||||
color: var(--primary-foreground);
|
||||
border-bottom-right-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.assistant & {
|
||||
// Flex column for text blocks, tool steps and cards. No parent
|
||||
// gap — auxiliary blocks (Thinking / ToolCall / actions) stack
|
||||
// flush, and the prose `.markdown` block adds its own 24px top
|
||||
// and bottom margins to mark itself as the message's focal point.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
border-bottom-left-radius: var(--radius-2);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// User-bubble row: pencil button sits to the LEFT of the bubble within
|
||||
// the right-aligned message line, so it visually "ends" at the bubble's
|
||||
// right edge while keeping the bubble in its original position.
|
||||
.bubbleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
// Anchor the prose block apart from any auxiliary rows (Thinking /
|
||||
// ToolCall / Suggested actions) above and below it. Reset when this
|
||||
// is the only / first / last child so the bubble doesn't grow taller
|
||||
// than its content.
|
||||
margin: 12px 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.65em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 0.65em;
|
||||
padding-left: 1.5em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.3em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
margin: 0.9em 0 0.4em;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.08em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1em;
|
||||
}
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--l2-border);
|
||||
padding: 0.1em 0 0.1em 0.8em;
|
||||
color: var(--l2-foreground);
|
||||
font-style: italic;
|
||||
margin: 0 0 0.65em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Geist Mono', 'Fira Code', monospace;
|
||||
font-size: 11.5px;
|
||||
border-radius: var(--radius-2);
|
||||
padding: 1px 4px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0 0 0.65em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-2);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
margin: 0 0 0.65em;
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.attachmentImage {
|
||||
max-width: 200px;
|
||||
max-height: 160px;
|
||||
border-radius: var(--radius-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachmentFile {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typingIndicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
height: 20px;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--l2-foreground);
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import '../blocks';
|
||||
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { Message, MessageBlock } from '../../types';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import { MessageContext } from '../MessageContext';
|
||||
import MessageFeedback from '../MessageFeedback';
|
||||
import ThinkingStep from '../ThinkingStep';
|
||||
import ToolCallStep from '../ToolCallStep';
|
||||
import UserMessageActions from '../UserMessageActions';
|
||||
|
||||
import styles from './MessageBubble.module.scss';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
displayText: block.displayText,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={index}
|
||||
className={styles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const isUser = message.role === 'user';
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
|
||||
const messageClass = cx(
|
||||
styles.message,
|
||||
isUser ? styles.user : styles.assistant,
|
||||
{
|
||||
[styles.compact]: isCompact,
|
||||
},
|
||||
);
|
||||
const bodyClass = cx(styles.body, { [styles.compact]: isCompact });
|
||||
|
||||
return (
|
||||
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
|
||||
<div className={bodyClass}>
|
||||
<div className={styles.bubbleRow}>
|
||||
<div className={styles.bubble}>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{message.attachments.map((att) => {
|
||||
const isImage = att.type.startsWith('image/');
|
||||
return isImage ? (
|
||||
<img
|
||||
key={att.name}
|
||||
src={att.dataUrl}
|
||||
alt={att.name}
|
||||
className={styles.attachmentImage}
|
||||
/>
|
||||
) : (
|
||||
<div key={att.name} className={styles.attachmentFile}>
|
||||
{att.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className={styles.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={styles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</MessageContext.Provider>
|
||||
)}
|
||||
|
||||
{!isUser && message.actions && message.actions.length > 0 && (
|
||||
<ActionsSection actions={message.actions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUser && <UserMessageActions message={message} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './MessageBubble';
|
||||
export { default } from './MessageBubble';
|
||||
@@ -0,0 +1,13 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,89 @@
|
||||
.feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 2px 0;
|
||||
// Driven by MessageBubble's --feedback-opacity (0 normally, 1 on hover/visible).
|
||||
opacity: var(--feedback-opacity, 0);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.visible {
|
||||
--feedback-opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
color: var(--l3-foreground) !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent-forest) !important;
|
||||
}
|
||||
|
||||
&.votedUp {
|
||||
color: var(--accent-primary) !important;
|
||||
}
|
||||
|
||||
&.votedDown {
|
||||
color: var(--accent-cherry) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 10px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
padding-left: 2px;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.feedbackTextarea {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.feedbackDialogFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
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';
|
||||
|
||||
import styles from './MessageFeedback.module.scss';
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// Negative-feedback dialog: collects an optional comment from the user.
|
||||
// Positive feedback is one-click; negative requires explicit Submit so
|
||||
// users can describe what was wrong.
|
||||
const [isNegativeDialogOpen, setIsNegativeDialogOpen] = useState(false);
|
||||
const [negativeComment, setNegativeComment] = useState('');
|
||||
|
||||
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;
|
||||
}
|
||||
if (rating === 'negative') {
|
||||
setNegativeComment('');
|
||||
setIsNegativeDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setVote(rating);
|
||||
submitMessageFeedback(message.id, rating);
|
||||
},
|
||||
[vote, message.id, submitMessageFeedback],
|
||||
);
|
||||
|
||||
const handleSubmitNegative = useCallback((): void => {
|
||||
setVote('negative');
|
||||
setIsNegativeDialogOpen(false);
|
||||
submitMessageFeedback(
|
||||
message.id,
|
||||
'negative',
|
||||
negativeComment.trim() || undefined,
|
||||
);
|
||||
}, [message.id, negativeComment, submitMessageFeedback]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.feedback, { [styles.visible]: isLastAssistant })}>
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple title={copied ? 'Copied!' : 'Copy'}>
|
||||
<Button
|
||||
className={styles.btn}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
color="secondary"
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Good response">
|
||||
<Button
|
||||
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => handleVote('positive')}
|
||||
>
|
||||
<ThumbsUp size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Bad response">
|
||||
<Button
|
||||
className={cx(styles.btn, {
|
||||
[styles.votedDown]: vote === 'negative',
|
||||
})}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => handleVote('negative')}
|
||||
>
|
||||
<ThumbsDown size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
{onRegenerate && (
|
||||
<TooltipSimple title="Regenerate">
|
||||
<Button
|
||||
className={styles.btn}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={styles.time}>
|
||||
{relativeTime} · {absoluteTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DialogWrapper
|
||||
open={isNegativeDialogOpen}
|
||||
onOpenChange={setIsNegativeDialogOpen}
|
||||
title="What went wrong?"
|
||||
subTitle="Your feedback helps us improve the assistant. Comments are optional."
|
||||
width="base"
|
||||
footer={
|
||||
<div className={styles.feedbackDialogFooter}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setIsNegativeDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="solid" color="primary" onClick={handleSubmitNegative}>
|
||||
Send feedback
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
className={styles.feedbackTextarea}
|
||||
placeholder="Tell us what was unhelpful, inaccurate, or unsafe…"
|
||||
value={negativeComment}
|
||||
onChange={(e): void => setNegativeComment(e.target.value)}
|
||||
rows={5}
|
||||
autoFocus
|
||||
maxLength={2000}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './MessageFeedback';
|
||||
export { default } from './MessageFeedback';
|
||||
@@ -0,0 +1,9 @@
|
||||
.streamingStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--l3-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ClarificationEventDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { StreamingEventItem } from '../../types';
|
||||
import ApprovalCard from '../ApprovalCard';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import ClarificationForm from '../ClarificationForm';
|
||||
import ThinkingStep from '../ThinkingStep';
|
||||
import ToolCallStep from '../ToolCallStep';
|
||||
|
||||
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
|
||||
import styles from './StreamingMessage.module.scss';
|
||||
|
||||
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…',
|
||||
};
|
||||
|
||||
function TypingDots(): JSX.Element {
|
||||
return (
|
||||
<span className={messageStyles.typingIndicator}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamingMessageProps {
|
||||
conversationId: string;
|
||||
/** Ordered timeline of text and tool-call events in arrival order. */
|
||||
events: StreamingEventItem[];
|
||||
status?: string;
|
||||
pendingApproval?: ApprovalEventDTO | null;
|
||||
pendingClarification?: ClarificationEventDTO | null;
|
||||
}
|
||||
|
||||
export default function StreamingMessage({
|
||||
conversationId,
|
||||
events,
|
||||
status = '',
|
||||
pendingApproval = null,
|
||||
pendingClarification = null,
|
||||
}: StreamingMessageProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const statusLabel = STATUS_LABEL[status] ?? '';
|
||||
const isEmpty =
|
||||
events.length === 0 && !pendingApproval && !pendingClarification;
|
||||
const isWaitingOnUser = Boolean(pendingApproval || pendingClarification);
|
||||
|
||||
const messageClass = cx(messageStyles.message, messageStyles.assistant, {
|
||||
[messageStyles.compact]: isCompact,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
<div className={messageStyles.bubble}>
|
||||
{/* Pre-output indicator — only before any events arrive. */}
|
||||
{isEmpty && statusLabel && (
|
||||
<span className={styles.streamingStatus}>{statusLabel}</span>
|
||||
)}
|
||||
{isEmpty && !statusLabel && <TypingDots />}
|
||||
|
||||
{/* 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={messageStyles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
})}
|
||||
{/* eslint-enable react/no-array-index-key */}
|
||||
|
||||
{/* While events are still streaming, append the typing dots so the
|
||||
user has a clear "more is coming" signal. Hidden when the agent
|
||||
is waiting on the user's input (an approval or clarification
|
||||
card already conveys that state). */}
|
||||
{!isEmpty && !isWaitingOnUser && <TypingDots />}
|
||||
|
||||
{/* Approval / clarification cards appended after any streamed text */}
|
||||
{pendingApproval && (
|
||||
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
|
||||
)}
|
||||
{pendingClarification && (
|
||||
<ClarificationForm
|
||||
conversationId={conversationId}
|
||||
clarification={pendingClarification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './StreamingMessage';
|
||||
export { default } from './StreamingMessage';
|
||||
@@ -0,0 +1,45 @@
|
||||
// Minimal expandable row — chevron + label, no icon, no left rail.
|
||||
// Matches the tool-call row treatment so consecutive thinking + tool
|
||||
// activity reads as one quiet "what the agent did" log.
|
||||
.row {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--l3-foreground);
|
||||
transition: color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './ThinkingStep.module.scss';
|
||||
|
||||
interface ThinkingStepProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Collapsible thinking row — chevron + label, content in the expanded body. */
|
||||
export default function ThinkingStep({
|
||||
content,
|
||||
}: ThinkingStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const toggle = (): void => setExpanded((v) => !v);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.header} onClick={toggle}>
|
||||
{expanded ? (
|
||||
<ChevronDown size={12} className={styles.chevron} />
|
||||
) : (
|
||||
<ChevronRight size={12} className={styles.chevron} />
|
||||
)}
|
||||
<span className={styles.label}>Thinking</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.body}>
|
||||
<p className={styles.content}>{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ThinkingStep';
|
||||
export { default } from './ThinkingStep';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user