mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-04 17:30:34 +01:00
Compare commits
72 Commits
feat/overv
...
feat/ai-as
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3313fa43ba | ||
|
|
fd17be12d1 | ||
|
|
c427bfaf10 | ||
|
|
02a5c50c74 | ||
|
|
613ebc325e | ||
|
|
20dd264ac1 | ||
|
|
8a7793794d | ||
|
|
680bcd08c3 | ||
|
|
5cf0e0fbb9 | ||
|
|
33c5239c6c | ||
|
|
6bc04e98aa | ||
|
|
7e4eda39dc | ||
|
|
3d944fe064 | ||
|
|
999857cd01 | ||
|
|
9175356c46 | ||
|
|
808add5401 | ||
|
|
c6683e075e | ||
|
|
3ac0c2f08a | ||
|
|
df6aefa243 | ||
|
|
fd4f5f085b | ||
|
|
3bc936282e | ||
|
|
292f99b922 | ||
|
|
46d630a38e | ||
|
|
c3f44b31fe | ||
|
|
2df265abbf | ||
|
|
02a743f8ab | ||
|
|
0c9f237369 | ||
|
|
8b13f004ed | ||
|
|
8c1d13bb38 | ||
|
|
b8073ab33b | ||
|
|
ad8f3328e0 | ||
|
|
4ddebbaa07 | ||
|
|
b55d1a2683 | ||
|
|
f11fb74584 | ||
|
|
4d91273d58 | ||
|
|
cc3da72aa5 | ||
|
|
d6ec1295d7 | ||
|
|
755390c4b5 | ||
|
|
adbd89aae9 | ||
|
|
b71de5b561 | ||
|
|
0dc1dfc1cb | ||
|
|
a672335a33 | ||
|
|
f4e5534e53 | ||
|
|
14a032119a | ||
|
|
e78dfc1622 | ||
|
|
49e1b43317 | ||
|
|
a240aed898 | ||
|
|
adc79062ed | ||
|
|
4b1bb1aef7 | ||
|
|
a0f3b27d15 | ||
|
|
b3413230c9 | ||
|
|
38f742a470 | ||
|
|
1c1fba9ea1 | ||
|
|
2dadb2b39b | ||
|
|
a595feb980 | ||
|
|
40f6994042 | ||
|
|
542d984d91 | ||
|
|
c6a885bc31 | ||
|
|
83a9d8fbfe | ||
|
|
17a015d244 | ||
|
|
7f9f383a95 | ||
|
|
17a7227831 | ||
|
|
9c8846ae63 | ||
|
|
18bb87f778 | ||
|
|
71a5e4500c | ||
|
|
7305470e62 | ||
|
|
1e140285ae | ||
|
|
4f039da2a6 | ||
|
|
d4afc49882 | ||
|
|
2bce8c9ea0 | ||
|
|
cae757041a | ||
|
|
adabd1d8db |
@@ -13,6 +13,8 @@ global:
|
||||
ingestion_url: <unset>
|
||||
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
|
||||
# mcp_url: <unset>
|
||||
# the url of the SigNoz AI Assistant server. when unset, the AI Assistant is hidden in the frontend.
|
||||
# ai_assistant_url: <unset>
|
||||
|
||||
##################### Version #####################
|
||||
version:
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.120.0
|
||||
image: signoz/signoz:v0.121.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.120.0
|
||||
image: signoz/signoz:v0.121.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.120.0}
|
||||
image: signoz/signoz:${VERSION:-v0.121.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.120.0}
|
||||
image: signoz/signoz:${VERSION:-v0.121.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -96,6 +96,122 @@ components:
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesPostableChannel:
|
||||
oneOf:
|
||||
- required:
|
||||
- discord_configs
|
||||
- required:
|
||||
- email_configs
|
||||
- required:
|
||||
- incidentio_configs
|
||||
- required:
|
||||
- pagerduty_configs
|
||||
- required:
|
||||
- slack_configs
|
||||
- required:
|
||||
- webhook_configs
|
||||
- required:
|
||||
- opsgenie_configs
|
||||
- required:
|
||||
- wechat_configs
|
||||
- required:
|
||||
- pushover_configs
|
||||
- required:
|
||||
- victorops_configs
|
||||
- required:
|
||||
- sns_configs
|
||||
- required:
|
||||
- telegram_configs
|
||||
- required:
|
||||
- webex_configs
|
||||
- required:
|
||||
- msteams_configs
|
||||
- required:
|
||||
- msteamsv2_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- rocketchat_configs
|
||||
- required:
|
||||
- mattermost_configs
|
||||
properties:
|
||||
discord_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigDiscordConfig'
|
||||
type: array
|
||||
email_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigEmailConfig'
|
||||
type: array
|
||||
incidentio_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigIncidentioConfig'
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigMattermostConfig'
|
||||
type: array
|
||||
msteams_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigMSTeamsConfig'
|
||||
type: array
|
||||
msteamsv2_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
opsgenie_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigOpsGenieConfig'
|
||||
type: array
|
||||
pagerduty_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigPagerdutyConfig'
|
||||
type: array
|
||||
pushover_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigPushoverConfig'
|
||||
type: array
|
||||
rocketchat_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigRocketchatConfig'
|
||||
type: array
|
||||
slack_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigSlackConfig'
|
||||
type: array
|
||||
sns_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigSNSConfig'
|
||||
type: array
|
||||
telegram_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigTelegramConfig'
|
||||
type: array
|
||||
victorops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigVictorOpsConfig'
|
||||
type: array
|
||||
webex_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigWebexConfig'
|
||||
type: array
|
||||
webhook_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigWebhookConfig'
|
||||
type: array
|
||||
wechat_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigWechatConfig'
|
||||
type: array
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
AlertmanagertypesPostableRoutePolicy:
|
||||
properties:
|
||||
channels:
|
||||
@@ -133,6 +249,10 @@ components:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesAuthDomainConfig:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
properties:
|
||||
googleAuthConfig:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
@@ -145,8 +265,15 @@ components:
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesAuthNProvider:
|
||||
enum:
|
||||
- google_auth
|
||||
- saml
|
||||
- email_password
|
||||
- oidc
|
||||
type: string
|
||||
AuthtypesAuthNProviderInfo:
|
||||
properties:
|
||||
relayStatePath:
|
||||
@@ -169,11 +296,15 @@ components:
|
||||
AuthtypesCallbackAuthNSupport:
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
|
||||
@@ -197,7 +328,7 @@ components:
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -323,7 +454,7 @@ components:
|
||||
AuthtypesPasswordAuthNSupport:
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesPatchableObjects:
|
||||
properties:
|
||||
@@ -2363,6 +2494,9 @@ components:
|
||||
type: object
|
||||
GlobaltypesConfig:
|
||||
properties:
|
||||
ai_assistant_url:
|
||||
nullable: true
|
||||
type: string
|
||||
external_url:
|
||||
type: string
|
||||
identN:
|
||||
@@ -2376,6 +2510,7 @@ components:
|
||||
- external_url
|
||||
- ingestion_url
|
||||
- mcp_url
|
||||
- ai_assistant_url
|
||||
type: object
|
||||
GlobaltypesIdentNConfig:
|
||||
properties:
|
||||
@@ -2632,6 +2767,158 @@ components:
|
||||
- list
|
||||
- grouped_list
|
||||
type: string
|
||||
LlmpricingruletypesGettablePricingRules:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRule'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
required:
|
||||
- items
|
||||
- total
|
||||
- offset
|
||||
- limit
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingCacheCosts:
|
||||
properties:
|
||||
mode:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRuleCacheMode'
|
||||
read:
|
||||
format: double
|
||||
type: number
|
||||
write:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- mode
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingRule:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
isOverride:
|
||||
type: boolean
|
||||
modelName:
|
||||
type: string
|
||||
modelPattern:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesStringSlice'
|
||||
orgId:
|
||||
type: string
|
||||
pricing:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMRulePricing'
|
||||
provider:
|
||||
type: string
|
||||
sourceId:
|
||||
type: string
|
||||
syncedAt:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
unit:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRuleUnit'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- modelName
|
||||
- provider
|
||||
- modelPattern
|
||||
- unit
|
||||
- pricing
|
||||
- isOverride
|
||||
- enabled
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingRuleCacheMode:
|
||||
enum:
|
||||
- subtract
|
||||
- additive
|
||||
- unknown
|
||||
type: string
|
||||
LlmpricingruletypesLLMPricingRuleUnit:
|
||||
enum:
|
||||
- per_million_tokens
|
||||
type: string
|
||||
LlmpricingruletypesLLMRulePricing:
|
||||
properties:
|
||||
cache:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingCacheCosts'
|
||||
input:
|
||||
format: double
|
||||
type: number
|
||||
output:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- input
|
||||
- output
|
||||
type: object
|
||||
LlmpricingruletypesStringSlice:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
LlmpricingruletypesUpdatableLLMPricingRule:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
isOverride:
|
||||
nullable: true
|
||||
type: boolean
|
||||
modelName:
|
||||
type: string
|
||||
modelPattern:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
pricing:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMRulePricing'
|
||||
provider:
|
||||
type: string
|
||||
sourceId:
|
||||
nullable: true
|
||||
type: string
|
||||
unit:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRuleUnit'
|
||||
required:
|
||||
- modelName
|
||||
- provider
|
||||
- modelPattern
|
||||
- unit
|
||||
- pricing
|
||||
- enabled
|
||||
type: object
|
||||
LlmpricingruletypesUpdatableLLMPricingRules:
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesUpdatableLLMPricingRule'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- rules
|
||||
type: object
|
||||
MetricsexplorertypesInspectMetricsRequest:
|
||||
properties:
|
||||
end:
|
||||
@@ -5513,7 +5800,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConfigReceiver'
|
||||
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -6890,6 +7177,63 @@ paths:
|
||||
summary: Delete auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns an auth domain by ID
|
||||
operationId: GetAuthDomain
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get auth domain by ID
|
||||
tags:
|
||||
- authdomains
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an auth domain
|
||||
@@ -7675,6 +8019,218 @@ paths:
|
||||
summary: Create bulk invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/llm_pricing_rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns all LLM pricing rules for the authenticated org, with pagination.
|
||||
operationId: ListLLMPricingRules
|
||||
parameters:
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesGettablePricingRules'
|
||||
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 pricing rules
|
||||
tags:
|
||||
- llmpricingrules
|
||||
put:
|
||||
deprecated: false
|
||||
description: Single write endpoint used by both the user and the Zeus sync job.
|
||||
Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true)
|
||||
are fully preserved when the request does not provide isOverride; only synced_at
|
||||
is stamped.
|
||||
operationId: CreateOrUpdateLLMPricingRules
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesUpdatableLLMPricingRules'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create or update pricing rules
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/llm_pricing_rules/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Hard-deletes a pricing rule. If auto-synced, it will be recreated
|
||||
on the next sync cycle.
|
||||
operationId: DeleteLLMPricingRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a single LLM pricing rule by ID.
|
||||
operationId: GetLLMPricingRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -16909,9 +17465,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- VIEWER
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
|
||||
20
frontend/.cursor/rules/ui-components-and-icons.mdc
Normal file
20
frontend/.cursor/rules/ui-components-and-icons.mdc
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts"]
|
||||
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"]
|
||||
}
|
||||
39763
frontend/package-lock.json
generated
Normal file
39763
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.10",
|
||||
"@signozhq/ui": "0.0.13",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -110,6 +110,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-addons-update": "15.6.3",
|
||||
"react-beautiful-dnd": "13.1.1",
|
||||
"react-chartjs-2": "4",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -133,6 +134,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",
|
||||
@@ -198,6 +200,7 @@
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260421.2",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-plugin-styled-components": "^1.12.0",
|
||||
"eslint-plugin-sonarjs": "4.0.2",
|
||||
@@ -231,6 +234,7 @@
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
"vite-plugin-image-optimizer": "2.0.3",
|
||||
@@ -240,7 +244,7 @@
|
||||
"*.(js|jsx|ts|tsx)": [
|
||||
"oxlint --fix",
|
||||
"oxfmt --write",
|
||||
"sh scripts/typecheck-staged.sh"
|
||||
"sh -c tsgo --noEmit"
|
||||
],
|
||||
"*.(scss|css)": [
|
||||
"stylelint"
|
||||
@@ -266,4 +270,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
frontend/req.md
Normal file
9
frontend/req.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# SigNoz AI Assistant
|
||||
|
||||
1. Chat interface (Side Drawer View)
|
||||
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
|
||||
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
|
||||
2. Input Section
|
||||
1. Users should be able to upload images / files to the chat
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
files="";
|
||||
|
||||
# lint-staged will pass all files in $1 $2 $3 etc. iterate and concat.
|
||||
for var in "$@"
|
||||
do
|
||||
files="$files \"$var\","
|
||||
done
|
||||
|
||||
# create temporary tsconfig which includes only passed files
|
||||
str="{
|
||||
\"extends\": \"./tsconfig.json\",
|
||||
\"include\": [ \"src/typings/**/*.ts\",\"src/**/*.d.ts\", \"./babel.config.js\", \"./jest.config.ts\", \"./.eslintrc.js\",\"./__mocks__\",\"./public\",\"./tests\",\"./commitlint.config.ts\",\"./webpack.config.js\",\"./webpack.config.prod.js\",\"./jest.setup.ts\",\"./**/*.d.ts\",$files]
|
||||
}"
|
||||
echo $str > tsconfig.tmp
|
||||
|
||||
# run typecheck using temp config
|
||||
tsc -p ./tsconfig.tmp
|
||||
|
||||
# capture exit code of tsc
|
||||
code=$?
|
||||
|
||||
# delete temp config
|
||||
rm ./tsconfig.tmp
|
||||
|
||||
exit $code
|
||||
@@ -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,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
} = useAppContext();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -99,6 +101,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;
|
||||
|
||||
|
||||
@@ -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,6 +61,7 @@ function App(): JSX.Element {
|
||||
org,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { hostname, pathname } = window.location;
|
||||
|
||||
@@ -212,6 +214,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 +244,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 {
|
||||
|
||||
@@ -317,3 +317,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,
|
||||
@@ -496,6 +497,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 = {
|
||||
|
||||
455
frontend/src/api/ai/chat.ts
Normal file
455
frontend/src/api/ai/chat.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* 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/generated/services/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,
|
||||
StatusEventDTO,
|
||||
ThinkingEventDTO,
|
||||
ThreadDetailResponseDTO,
|
||||
ThreadListResponseDTO,
|
||||
ThreadSummaryDTO,
|
||||
ToolCallEventDTO,
|
||||
ToolResultEventDTO,
|
||||
} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { AIAssistantInstance, getAIBaseUrl } from './instance';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function fetchSSEWithAuth(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const send = async (token: string | null): Promise<Response> =>
|
||||
fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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 Error(`SSE stream failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
yield* readSSEReader(res.body.getReader());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval / Clarification / Cancel actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function approveExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const 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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
58
frontend/src/api/ai/instance.ts
Normal file
58
frontend/src/api/ai/instance.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
interceptorRejected,
|
||||
interceptorsRequestBasePath,
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
|
||||
/** Path-only base for the AI Assistant API. */
|
||||
export const AI_API_PATH = '/api/v1/assistant';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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.response.use(
|
||||
interceptorsResponse,
|
||||
interceptorRejected,
|
||||
);
|
||||
1820
frontend/src/api/generated/services/ai-assistant/index.ts
Normal file
1820
frontend/src/api/generated/services/ai-assistant/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@ import type {
|
||||
AuthtypesUpdateableAuthDomainDTO,
|
||||
CreateAuthDomain200,
|
||||
DeleteAuthDomainPathParameters,
|
||||
GetAuthDomain200,
|
||||
GetAuthDomainPathParameters,
|
||||
ListAuthDomains200,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAuthDomainPathParameters,
|
||||
@@ -277,6 +279,109 @@ export const useDeleteAuthDomain = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint returns an auth domain by ID
|
||||
* @summary Get auth domain by ID
|
||||
*/
|
||||
export const getAuthDomain = (
|
||||
{ id }: GetAuthDomainPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetAuthDomain200>({
|
||||
url: `/api/v1/domains/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAuthDomainQueryKey = ({
|
||||
id,
|
||||
}: GetAuthDomainPathParameters) => {
|
||||
return [`/api/v1/domains/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetAuthDomainQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAuthDomain>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetAuthDomainPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAuthDomain>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetAuthDomainQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthDomain>>> = ({
|
||||
signal,
|
||||
}) => getAuthDomain({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAuthDomain>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAuthDomainQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAuthDomain>>
|
||||
>;
|
||||
export type GetAuthDomainQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get auth domain by ID
|
||||
*/
|
||||
|
||||
export function useGetAuthDomain<
|
||||
TData = Awaited<ReturnType<typeof getAuthDomain>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetAuthDomainPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAuthDomain>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAuthDomainQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get auth domain by ID
|
||||
*/
|
||||
export const invalidateGetAuthDomain = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetAuthDomainPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetAuthDomainQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates an auth domain
|
||||
* @summary Update auth domain
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigReceiverDTO,
|
||||
CreateChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
@@ -122,14 +123,14 @@ export const invalidateListChannels = async (
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const createChannel = (
|
||||
configReceiverDTO: BodyType<ConfigReceiverDTO>,
|
||||
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateChannel201>({
|
||||
url: `/api/v1/channels`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: configReceiverDTO,
|
||||
data: alertmanagertypesPostableChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -141,13 +142,13 @@ export const getCreateChannelMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createChannel'];
|
||||
@@ -161,7 +162,7 @@ export const getCreateChannelMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
{ data: BodyType<ConfigReceiverDTO> }
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -174,7 +175,8 @@ export const getCreateChannelMutationOptions = <
|
||||
export type CreateChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createChannel>>
|
||||
>;
|
||||
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
|
||||
export type CreateChannelMutationBody =
|
||||
BodyType<AlertmanagertypesPostableChannelDTO>;
|
||||
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -187,13 +189,13 @@ export const useCreateChannel = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateChannelMutationOptions(options);
|
||||
|
||||
399
frontend/src/api/generated/services/llmpricingrules/index.ts
Normal file
399
frontend/src/api/generated/services/llmpricingrules/index.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
DeleteLLMPricingRulePathParameters,
|
||||
GetLLMPricingRule200,
|
||||
GetLLMPricingRulePathParameters,
|
||||
ListLLMPricingRules200,
|
||||
ListLLMPricingRulesParams,
|
||||
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns all LLM pricing rules for the authenticated org, with pagination.
|
||||
* @summary List pricing rules
|
||||
*/
|
||||
export const listLLMPricingRules = (
|
||||
params?: ListLLMPricingRulesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListLLMPricingRules200>({
|
||||
url: `/api/v1/llm_pricing_rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListLLMPricingRulesQueryKey = (
|
||||
params?: ListLLMPricingRulesParams,
|
||||
) => {
|
||||
return [`/api/v1/llm_pricing_rules`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListLLMPricingRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listLLMPricingRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListLLMPricingRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listLLMPricingRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListLLMPricingRulesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listLLMPricingRules>>
|
||||
> = ({ signal }) => listLLMPricingRules(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listLLMPricingRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListLLMPricingRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listLLMPricingRules>>
|
||||
>;
|
||||
export type ListLLMPricingRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List pricing rules
|
||||
*/
|
||||
|
||||
export function useListLLMPricingRules<
|
||||
TData = Awaited<ReturnType<typeof listLLMPricingRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListLLMPricingRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listLLMPricingRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListLLMPricingRulesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List pricing rules
|
||||
*/
|
||||
export const invalidateListLLMPricingRules = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListLLMPricingRulesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListLLMPricingRulesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.
|
||||
* @summary Create or update pricing rules
|
||||
*/
|
||||
export const createOrUpdateLLMPricingRules = (
|
||||
llmpricingruletypesUpdatableLLMPricingRulesDTO: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/llm_pricing_rules`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: llmpricingruletypesUpdatableLLMPricingRulesDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateOrUpdateLLMPricingRulesMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
|
||||
TError,
|
||||
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
|
||||
TError,
|
||||
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createOrUpdateLLMPricingRules'];
|
||||
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 createOrUpdateLLMPricingRules>>,
|
||||
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createOrUpdateLLMPricingRules(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateOrUpdateLLMPricingRulesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>
|
||||
>;
|
||||
export type CreateOrUpdateLLMPricingRulesMutationBody =
|
||||
BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO>;
|
||||
export type CreateOrUpdateLLMPricingRulesMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create or update pricing rules
|
||||
*/
|
||||
export const useCreateOrUpdateLLMPricingRules = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
|
||||
TError,
|
||||
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
|
||||
TError,
|
||||
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions =
|
||||
getCreateOrUpdateLLMPricingRulesMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Hard-deletes a pricing rule. If auto-synced, it will be recreated on the next sync cycle.
|
||||
* @summary Delete a pricing rule
|
||||
*/
|
||||
export const deleteLLMPricingRule = ({
|
||||
id,
|
||||
}: DeleteLLMPricingRulePathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/llm_pricing_rules/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteLLMPricingRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteLLMPricingRulePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteLLMPricingRulePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteLLMPricingRule'];
|
||||
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 deleteLLMPricingRule>>,
|
||||
{ pathParams: DeleteLLMPricingRulePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteLLMPricingRule(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteLLMPricingRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteLLMPricingRule>>
|
||||
>;
|
||||
|
||||
export type DeleteLLMPricingRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a pricing rule
|
||||
*/
|
||||
export const useDeleteLLMPricingRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteLLMPricingRulePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
|
||||
TError,
|
||||
{ pathParams: DeleteLLMPricingRulePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteLLMPricingRuleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a single LLM pricing rule by ID.
|
||||
* @summary Get a pricing rule
|
||||
*/
|
||||
export const getLLMPricingRule = (
|
||||
{ id }: GetLLMPricingRulePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetLLMPricingRule200>({
|
||||
url: `/api/v1/llm_pricing_rules/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetLLMPricingRuleQueryKey = ({
|
||||
id,
|
||||
}: GetLLMPricingRulePathParameters) => {
|
||||
return [`/api/v1/llm_pricing_rules/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetLLMPricingRuleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getLLMPricingRule>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetLLMPricingRulePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getLLMPricingRule>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetLLMPricingRuleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getLLMPricingRule>>
|
||||
> = ({ signal }) => getLLMPricingRule({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getLLMPricingRule>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetLLMPricingRuleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getLLMPricingRule>>
|
||||
>;
|
||||
export type GetLLMPricingRuleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a pricing rule
|
||||
*/
|
||||
|
||||
export function useGetLLMPricingRule<
|
||||
TData = Awaited<ReturnType<typeof getLLMPricingRule>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetLLMPricingRulePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getLLMPricingRule>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetLLMPricingRuleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a pricing rule
|
||||
*/
|
||||
export const invalidateGetLLMPricingRule = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetLLMPricingRulePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetLLMPricingRuleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
22
frontend/src/components/CodeBlock/CodeBlock.module.scss
Normal file
22
frontend/src/components/CodeBlock/CodeBlock.module.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
.codeBlock {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.codeBlockSyntaxHighlighter {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
color: var(--l2-foreground) !important;
|
||||
|
||||
pre {
|
||||
color: var(--l2-foreground) !important;
|
||||
font-family: 'Geist Mono' !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--l1-foreground) !important;
|
||||
font-family: 'Geist Mono' !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
46
frontend/src/components/CodeBlock/CodeBlock.test.tsx
Normal file
46
frontend/src/components/CodeBlock/CodeBlock.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import CodeBlock from './CodeBlock';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): [unknown, (text: string) => void] => [
|
||||
undefined,
|
||||
mockCopyToClipboard,
|
||||
],
|
||||
}));
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
beforeEach(() => {
|
||||
mockCopyToClipboard.mockReset();
|
||||
});
|
||||
|
||||
it('renders code block mode by default', () => {
|
||||
render(<CodeBlock code={'const x = 1;\n'} language="javascript" />);
|
||||
|
||||
const container = screen.getByTestId('code-block-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container).toHaveTextContent('const x = 1;');
|
||||
});
|
||||
|
||||
it('renders inline code when inline is true', () => {
|
||||
render(<CodeBlock code="inline value" inline />);
|
||||
|
||||
const inlineCode = screen.getByText('inline value');
|
||||
expect(inlineCode.tagName.toLowerCase()).toBe('code');
|
||||
expect(screen.queryByTestId('code-block-container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies code and triggers callback', async () => {
|
||||
const onCopy = jest.fn();
|
||||
render(<CodeBlock code="SELECT * FROM logs;" onCopy={onCopy} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /copy code/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('SELECT * FROM logs;');
|
||||
});
|
||||
expect(onCopy).toHaveBeenCalledWith('SELECT * FROM logs;');
|
||||
});
|
||||
});
|
||||
89
frontend/src/components/CodeBlock/CodeBlock.tsx
Normal file
89
frontend/src/components/CodeBlock/CodeBlock.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import SyntaxHighlighter, {
|
||||
a11yDark,
|
||||
} from 'components/MarkdownRenderer/syntaxHighlighter';
|
||||
|
||||
import styles from './CodeBlock.module.scss';
|
||||
|
||||
export interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
showLineNumbers?: boolean;
|
||||
showCopyButton?: boolean;
|
||||
onCopy?: (copiedCode: string) => void;
|
||||
}
|
||||
|
||||
function CodeBlock({
|
||||
code,
|
||||
language = 'text',
|
||||
className,
|
||||
inline = false,
|
||||
showLineNumbers = false,
|
||||
showCopyButton = true,
|
||||
onCopy,
|
||||
}: CodeBlockProps): JSX.Element {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const normalizedCode = useMemo(() => code?.replace(/\n$/, '') ?? '', [code]);
|
||||
|
||||
const handleCopy = (): void => {
|
||||
copyToClipboard(normalizedCode);
|
||||
setIsCopied(true);
|
||||
onCopy?.(normalizedCode);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (inline) {
|
||||
return <code className={className}>{normalizedCode}</code>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.codeBlock} ${className}`}
|
||||
style={{ position: 'relative' }}
|
||||
data-testid="code-block-container"
|
||||
>
|
||||
{showCopyButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
prefix={isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
aria-label="Copy code"
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
|
||||
/>
|
||||
) : null}
|
||||
<SyntaxHighlighter
|
||||
style={a11yDark}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLongLines
|
||||
className={styles.codeBlockSyntaxHighlighter}
|
||||
>
|
||||
{normalizedCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CodeBlock.defaultProps = {
|
||||
language: 'text',
|
||||
className: undefined,
|
||||
inline: false,
|
||||
showLineNumbers: false,
|
||||
showCopyButton: true,
|
||||
onCopy: undefined,
|
||||
};
|
||||
|
||||
export default CodeBlock;
|
||||
@@ -1,33 +0,0 @@
|
||||
.error-state-container {
|
||||
height: 240px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
.error-state-container-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.error-state-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-state-additional-messages {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.error-state-additional-text {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import APIError from '../../types/api/error';
|
||||
|
||||
import './Common.styles.scss';
|
||||
|
||||
interface ErrorStateComponentProps {
|
||||
message?: string;
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<ErrorStateComponentProps> = {
|
||||
message: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
function ErrorStateComponent({
|
||||
message,
|
||||
error,
|
||||
}: ErrorStateComponentProps): JSX.Element {
|
||||
// Handle API Error object
|
||||
if (error) {
|
||||
const mainMessage = error.getErrorMessage();
|
||||
const additionalErrors = error.getErrorDetails().error.errors || [];
|
||||
|
||||
return (
|
||||
<div className="error-state-container">
|
||||
<div className="error-state-container-content">
|
||||
<Typography className="error-state-text">{mainMessage}</Typography>
|
||||
{additionalErrors.length > 0 && (
|
||||
<div className="error-state-additional-messages">
|
||||
{additionalErrors.map((additionalError) => (
|
||||
<Typography
|
||||
key={`error-${additionalError.message}`}
|
||||
className="error-state-additional-text"
|
||||
>
|
||||
• {additionalError.message}
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle simple string message (backwards compatibility)
|
||||
return (
|
||||
<div className="error-state-container">
|
||||
<div className="error-state-container-content">
|
||||
<Typography className="error-state-text">{message}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ErrorStateComponent.defaultProps = defaultProps;
|
||||
|
||||
export default ErrorStateComponent;
|
||||
@@ -1,4 +0,0 @@
|
||||
.custom-date-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { DatePicker } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
CustomTimeType,
|
||||
LexicalContext,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './RangePickerModal.styles.scss';
|
||||
|
||||
interface RangePickerModalProps {
|
||||
setCustomDTPickerVisible: Dispatch<SetStateAction<boolean>>;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onCustomDateHandler: (
|
||||
dateTimeRange: DateTimeRangeType,
|
||||
lexicalContext?: LexicalContext | undefined,
|
||||
) => void;
|
||||
selectedTime: string;
|
||||
onTimeChange?: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function RangePickerModal(props: RangePickerModalProps): JSX.Element {
|
||||
const {
|
||||
setCustomDTPickerVisible,
|
||||
setIsOpen,
|
||||
onCustomDateHandler,
|
||||
selectedTime,
|
||||
onTimeChange,
|
||||
} = props;
|
||||
const { RangePicker } = DatePicker;
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// Using any type here because antd's DatePicker expects its own internal Dayjs type
|
||||
// which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc).
|
||||
const disabledDate = (current: any): boolean => {
|
||||
const currentDay = dayjs(current);
|
||||
return currentDay.isAfter(dayjs());
|
||||
};
|
||||
|
||||
const onPopoverClose = (visible: boolean): void => {
|
||||
if (!visible) {
|
||||
setCustomDTPickerVisible(false);
|
||||
}
|
||||
setIsOpen(visible);
|
||||
};
|
||||
|
||||
const onModalOkHandler = (date_time: any): void => {
|
||||
if (date_time?.[1]) {
|
||||
onPopoverClose(false);
|
||||
}
|
||||
onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER);
|
||||
};
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const rangeValue: [Dayjs, Dayjs] = useMemo(
|
||||
() => [
|
||||
dayjs(minTime / 1000_000).tz(timezone.value),
|
||||
dayjs(maxTime / 1000_000).tz(timezone.value),
|
||||
],
|
||||
[maxTime, minTime, timezone.value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="custom-date-picker">
|
||||
<RangePicker
|
||||
disabledDate={disabledDate}
|
||||
allowClear
|
||||
showTime
|
||||
format={(date: Dayjs): string =>
|
||||
date.tz(timezone.value).format(DATE_TIME_FORMATS.ISO_DATETIME)
|
||||
}
|
||||
onOk={onModalOkHandler}
|
||||
data-1p-ignore
|
||||
{...(selectedTime === 'custom' &&
|
||||
!onTimeChange && {
|
||||
value: rangeValue,
|
||||
})}
|
||||
// use default value if onTimeChange is provided
|
||||
{...(selectedTime === 'custom' &&
|
||||
onTimeChange && {
|
||||
defaultValue: rangeValue,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RangePickerModal.defaultProps = {
|
||||
onTimeChange: undefined,
|
||||
};
|
||||
|
||||
export default RangePickerModal;
|
||||
@@ -1,93 +0,0 @@
|
||||
.details-drawer {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
.ant-drawer-header {
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.ant-drawer-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-drawer-close {
|
||||
margin-inline-end: 0px;
|
||||
padding: 0px;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
padding-left: 16px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.details-drawer-tabs {
|
||||
margin-top: 32px;
|
||||
|
||||
.ant-tabs-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 114px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
padding: 7px 20px;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.ant-btn:hover {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.ant-tabs-tab + .ant-tabs-tab {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Drawer, Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import './DetailsDrawer.styles.scss';
|
||||
|
||||
interface IDetailsDrawerProps {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
title: string;
|
||||
descriptiveContent: JSX.Element;
|
||||
defaultActiveKey: string;
|
||||
items: TabsProps['items'];
|
||||
detailsDrawerClassName?: string;
|
||||
tabBarExtraContent?: JSX.Element;
|
||||
}
|
||||
|
||||
function DetailsDrawer(props: IDetailsDrawerProps): JSX.Element {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
descriptiveContent,
|
||||
defaultActiveKey,
|
||||
detailsDrawerClassName,
|
||||
items,
|
||||
tabBarExtraContent,
|
||||
} = props;
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
open={open}
|
||||
afterOpenChange={setOpen}
|
||||
mask={false}
|
||||
title={title}
|
||||
onClose={(): void => setOpen(false)}
|
||||
className="details-drawer"
|
||||
>
|
||||
<div>{descriptiveContent}</div>
|
||||
<Tabs
|
||||
items={items}
|
||||
addIcon
|
||||
defaultActiveKey={defaultActiveKey}
|
||||
animated
|
||||
className={cx('details-drawer-tabs', detailsDrawerClassName)}
|
||||
tabBarExtraContent={tabBarExtraContent}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
DetailsDrawer.defaultProps = {
|
||||
detailsDrawerClassName: '',
|
||||
tabBarExtraContent: null,
|
||||
};
|
||||
|
||||
export default DetailsDrawer;
|
||||
@@ -46,6 +46,7 @@ function DeleteMemberDialog({
|
||||
color="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : title}
|
||||
@@ -63,7 +64,6 @@ function DeleteMemberDialog({
|
||||
}}
|
||||
title={title}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
footer={footer}
|
||||
|
||||
@@ -28,18 +28,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -48,7 +36,7 @@
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--border);
|
||||
box-sizing: border-box;
|
||||
|
||||
&--disabled {
|
||||
@@ -65,8 +53,8 @@
|
||||
}
|
||||
|
||||
&__email-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
@@ -178,36 +166,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.delete-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-link-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
@@ -264,13 +222,6 @@
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
min-width: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ function EditMemberDrawer({
|
||||
try {
|
||||
await rawRetry();
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
|
||||
refetchUser();
|
||||
void refetchUser();
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
@@ -250,7 +250,7 @@ function EditMemberDrawer({
|
||||
});
|
||||
}
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
refetchUser();
|
||||
void refetchUser();
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
@@ -319,7 +319,7 @@ function EditMemberDrawer({
|
||||
}),
|
||||
];
|
||||
});
|
||||
refetchUser();
|
||||
void refetchUser();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -340,7 +340,7 @@ function EditMemberDrawer({
|
||||
onComplete();
|
||||
}
|
||||
|
||||
refetchUser();
|
||||
void refetchUser();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -465,7 +465,6 @@ function EditMemberDrawer({
|
||||
prev.filter((err) => err.context !== 'Name update'),
|
||||
);
|
||||
}}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser || isDeleted}
|
||||
/>
|
||||
@@ -631,7 +630,7 @@ function EditMemberDrawer({
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={handleClose}>
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -641,6 +640,7 @@ function EditMemberDrawer({
|
||||
color="primary"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
|
||||
@@ -44,9 +44,8 @@ function ResetLinkDialog({
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import { withErrorBoundary } from './index';
|
||||
|
||||
/**
|
||||
* Example component that can throw errors
|
||||
*/
|
||||
function ProblematicComponent(): JSX.Element {
|
||||
const [shouldThrow, setShouldThrow] = useState(false);
|
||||
|
||||
if (shouldThrow) {
|
||||
throw new Error('This is a test error from ProblematicComponent!');
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Problematic Component</h3>
|
||||
<p>This component can throw errors when the button is clicked.</p>
|
||||
<Button type="primary" onClick={(): void => setShouldThrow(true)} danger>
|
||||
Trigger Error
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic usage - wraps component with default error boundary
|
||||
*/
|
||||
export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent);
|
||||
|
||||
/**
|
||||
* Usage with custom fallback component
|
||||
*/
|
||||
function CustomErrorFallback(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{ padding: '20px', border: '1px solid red', borderRadius: '4px' }}
|
||||
>
|
||||
<h4 style={{ color: 'red' }}>Custom Error Fallback</h4>
|
||||
<p>Something went wrong in this specific component!</p>
|
||||
<Button onClick={(): void => window.location.reload()}>Reload Page</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SafeProblematicComponentWithCustomFallback = withErrorBoundary(
|
||||
ProblematicComponent,
|
||||
{
|
||||
fallback: <CustomErrorFallback />,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Usage with custom error handler
|
||||
*/
|
||||
export const SafeProblematicComponentWithErrorHandler = withErrorBoundary(
|
||||
ProblematicComponent,
|
||||
{
|
||||
onError: (error, errorInfo) => {
|
||||
console.error('Custom error handler:', error);
|
||||
console.error('Error info:', errorInfo);
|
||||
// You could also send to analytics, logging service, etc.
|
||||
},
|
||||
sentryOptions: {
|
||||
tags: {
|
||||
section: 'dashboard',
|
||||
priority: 'high',
|
||||
},
|
||||
level: 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Example of wrapping an existing component from the codebase
|
||||
*/
|
||||
function ExistingComponent({
|
||||
title,
|
||||
data,
|
||||
}: {
|
||||
title: string;
|
||||
data: any[];
|
||||
}): JSX.Element {
|
||||
// This could be any existing component that might throw errors
|
||||
return (
|
||||
<div>
|
||||
<h4>{title}</h4>
|
||||
<ul>
|
||||
{data.map((item, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={index}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SafeExistingComponent = withErrorBoundary(ExistingComponent, {
|
||||
sentryOptions: {
|
||||
tags: {
|
||||
component: 'ExistingComponent',
|
||||
feature: 'data-display',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Usage examples in a container component
|
||||
*/
|
||||
export function ErrorBoundaryExamples(): JSX.Element {
|
||||
const sampleData = [
|
||||
{ name: 'Item 1' },
|
||||
{ name: 'Item 2' },
|
||||
{ name: 'Item 3' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h2>Error Boundary HOC Examples</h2>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>1. Basic Usage</h3>
|
||||
<SafeProblematicComponent />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>2. With Custom Fallback</h3>
|
||||
<SafeProblematicComponentWithCustomFallback />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>3. With Custom Error Handler</h3>
|
||||
<SafeProblematicComponentWithErrorHandler />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>4. Wrapped Existing Component</h3>
|
||||
<SafeExistingComponent title="Sample Data" data={sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(--bg-robin-500);
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn__pulse-dot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
animation: header-ai-assistant-dot-pulse 1.5s ease-in-out infinite;
|
||||
transform: scale(0.8);
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@keyframes header-ai-assistant-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
.share-modal-content,
|
||||
.feedback-modal-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
@@ -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,44 @@ function HeaderRightSection({
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
const pendingUserInputCount: number = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
const showHeaderPendingBadge =
|
||||
pendingUserInputCount > 0 && !isDrawerOpen && !isModalOpen;
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{isAIAssistantEnabled && !isDrawerOpen && (
|
||||
<div className="header-ai-assistant-btn-container">
|
||||
{showHeaderPendingBadge ? (
|
||||
<span className="header-ai-assistant-btn__badge" aria-hidden>
|
||||
<span className="header-ai-assistant-btn__pulse-dot">
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<Tooltip title="AI Assistant">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={openAIAssistant}
|
||||
aria-label={
|
||||
showHeaderPendingBadge
|
||||
? pendingUserInputCount === 1
|
||||
? 'Open AI Assistant, 1 action needs your response'
|
||||
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
|
||||
: 'Open AI Assistant'
|
||||
}
|
||||
prefix={<AIAssistantIcon />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
@@ -83,12 +128,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,8 +151,10 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Announcements"
|
||||
prefix={<Inbox size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
@@ -129,12 +177,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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
Callout,
|
||||
@@ -294,10 +294,8 @@ function InviteMembersModal({
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
title={getValidationErrorMessage()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.query-builder-search-wrapper {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-bottom: none;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Dispatch, SetStateAction, useEffect } from 'react';
|
||||
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import './QueryBuilderSearchWrapper.styles.scss';
|
||||
|
||||
function QueryBuilderSearchWrapper({
|
||||
log,
|
||||
filters,
|
||||
contextQuery,
|
||||
isEdit,
|
||||
suffixIcon,
|
||||
setFilters,
|
||||
setContextQuery,
|
||||
}: QueryBuilderSearchWraperProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
|
||||
useEffect(() => {
|
||||
setContextQuery(initialContextQuery);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = (tagFilters: TagFilter): void => {
|
||||
const tagFiltersLength = tagFilters.items.length;
|
||||
|
||||
if (
|
||||
(!tagFiltersLength && (!filters || !filters.items.length)) ||
|
||||
tagFiltersLength === filters?.items.length ||
|
||||
!contextQuery
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextQuery: Query = {
|
||||
...contextQuery,
|
||||
builder: {
|
||||
...contextQuery.builder,
|
||||
queryData: contextQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
filters: tagFilters,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
setFilters({ ...tagFilters });
|
||||
setContextQuery({ ...nextQuery });
|
||||
};
|
||||
|
||||
if (!contextQuery || !isEdit) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryBuilderSearch
|
||||
query={contextQuery?.builder.queryData[0]}
|
||||
onChange={handleSearch}
|
||||
className="query-builder-search-wrapper"
|
||||
suffixIcon={suffixIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface QueryBuilderSearchWraperProps {
|
||||
log: ILog;
|
||||
isEdit: boolean;
|
||||
contextQuery: Query | undefined;
|
||||
setContextQuery: Dispatch<SetStateAction<Query | undefined>>;
|
||||
filters: TagFilter | null;
|
||||
setFilters: Dispatch<SetStateAction<TagFilter | null>>;
|
||||
suffixIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
QueryBuilderSearchWrapper.defaultProps = {
|
||||
suffixIcon: undefined,
|
||||
};
|
||||
|
||||
export default QueryBuilderSearchWrapper;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
export const rawLineStyle: CSSProperties = {};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Button } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const ButtonContainer = styled(Button)`
|
||||
&&& {
|
||||
padding-left: 0;
|
||||
}
|
||||
`;
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.custom-multiselect-dropdown {
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e8e8e8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.all-option {
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.loading-panel-data {
|
||||
padding: 24px 0;
|
||||
height: 240px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
.loading-panel-data-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-gif {
|
||||
height: 72px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import './PanelDataLoading.styles.scss';
|
||||
|
||||
export function PanelDataLoading(): JSX.Element {
|
||||
return (
|
||||
<div className="loading-panel-data">
|
||||
<div className="loading-panel-data-content">
|
||||
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
|
||||
|
||||
<Typography.Text>Fetching data...</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
@@ -126,12 +126,6 @@
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
min-width: 40px;
|
||||
}
|
||||
@@ -152,6 +146,7 @@
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
|
||||
@@ -22,9 +22,8 @@ function KeyCreatedPhase({
|
||||
<div className="add-key-modal__key-display">
|
||||
<span className="add-key-modal__key-text">{createdKey.key}</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
className="add-key-modal__copy-btn"
|
||||
>
|
||||
|
||||
@@ -106,7 +106,7 @@ function KeyFormPhase({
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -115,7 +115,6 @@ function KeyFormPhase({
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
|
||||
@@ -136,7 +136,7 @@ function EditKeyForm({
|
||||
</form>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<Button variant="ghost" color="destructive" onClick={onRevokeClick}>
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { RevokeKeyContent } from '../RevokeKeyModal';
|
||||
import { RevokeKeyFooter } from '../RevokeKeyModal';
|
||||
import EditKeyForm from './EditKeyForm';
|
||||
import type { FormValues } from './types';
|
||||
import { DEFAULT_FORM_VALUES, ExpiryMode } from './types';
|
||||
@@ -158,17 +158,25 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
}
|
||||
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
|
||||
className={
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
isRevokeConfirmOpen ? 'alert-dialog sa-delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
footer={
|
||||
isRevokeConfirmOpen ? (
|
||||
<RevokeKeyFooter
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setIsRevokeConfirmOpen(false)}
|
||||
onConfirm={handleRevoke}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setIsRevokeConfirmOpen(false)}
|
||||
onConfirm={handleRevoke}
|
||||
/>
|
||||
<>
|
||||
Revoking this key will permanently invalidate it. Any systems using this
|
||||
key will lose access immediately.
|
||||
</>
|
||||
) : (
|
||||
<EditKeyForm
|
||||
register={register}
|
||||
|
||||
@@ -72,7 +72,6 @@ function OverviewTab({
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
className="sa-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,39 +17,32 @@ import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface RevokeKeyContentProps {
|
||||
export interface RevokeKeyFooterProps {
|
||||
isRevoking: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function RevokeKeyContent({
|
||||
export function RevokeKeyFooter({
|
||||
isRevoking,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RevokeKeyContentProps): JSX.Element {
|
||||
}: RevokeKeyFooterProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using this key
|
||||
will lose access immediately.
|
||||
</p>
|
||||
<div className="delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="solid" color="secondary" onClick={onCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -112,15 +105,19 @@ function RevokeKeyModal(): JSX.Element {
|
||||
}}
|
||||
title={`Revoke ${keyName ?? 'key'}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
footer={
|
||||
<RevokeKeyFooter
|
||||
isRevoking={isRevoking}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
Revoking this key will permanently invalidate it. Any systems using this key
|
||||
will lose access immediately.
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
@@ -166,18 +168,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,7 +176,7 @@
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -195,8 +185,8 @@
|
||||
}
|
||||
|
||||
&__input-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
@@ -129,7 +129,7 @@ function ServiceAccountDrawer({
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
setLocalName(account?.name ?? '');
|
||||
setKeysPage(1);
|
||||
void setKeysPage(1);
|
||||
}
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
|
||||
@@ -176,7 +176,7 @@ function ServiceAccountDrawer({
|
||||
}
|
||||
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
|
||||
if (keysPage > maxPage) {
|
||||
setKeysPage(maxPage);
|
||||
void setKeysPage(maxPage);
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage, setKeysPage]);
|
||||
|
||||
@@ -214,8 +214,8 @@ function ServiceAccountDrawer({
|
||||
data: { name: localName },
|
||||
});
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
|
||||
refetchAccount();
|
||||
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
void refetchAccount();
|
||||
void queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
} catch (err) {
|
||||
setSaveErrors((prev) =>
|
||||
prev.map((e) =>
|
||||
@@ -337,8 +337,8 @@ function ServiceAccountDrawer({
|
||||
onSuccess({ closeDrawer: false });
|
||||
}
|
||||
|
||||
refetchAccount();
|
||||
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
void refetchAccount();
|
||||
void queryClient.invalidateQueries(getListServiceAccountsQueryKey());
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -357,12 +357,12 @@ function ServiceAccountDrawer({
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsDeleteOpen(null);
|
||||
setIsAddKeyOpen(null);
|
||||
setSelectedAccountId(null);
|
||||
setActiveTab(null);
|
||||
setKeysPage(null);
|
||||
setEditKeyId(null);
|
||||
void setIsDeleteOpen(null);
|
||||
void setIsAddKeyOpen(null);
|
||||
void setSelectedAccountId(null);
|
||||
void setActiveTab(null);
|
||||
void setKeysPage(null);
|
||||
void setEditKeyId(null);
|
||||
setSaveErrors([]);
|
||||
}, [
|
||||
setSelectedAccountId,
|
||||
@@ -379,12 +379,13 @@ function ServiceAccountDrawer({
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
size="sm"
|
||||
onChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as ServiceAccountDrawerTab);
|
||||
void setActiveTab(val as ServiceAccountDrawerTab);
|
||||
if (val !== ServiceAccountDrawerTab.Keys) {
|
||||
setKeysPage(null);
|
||||
setEditKeyId(null);
|
||||
void setKeysPage(null);
|
||||
void setEditKeyId(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -415,7 +416,7 @@ function ServiceAccountDrawer({
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
setIsAddKeyOpen(true);
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
@@ -503,7 +504,7 @@ function ServiceAccountDrawer({
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
setIsDeleteOpen(true);
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
@@ -512,7 +513,7 @@ function ServiceAccountDrawer({
|
||||
)}
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={handleClose}>
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { css, FlattenSimpleInterpolation } from 'styled-components';
|
||||
|
||||
const cssProperty = (key: any, value: any): FlattenSimpleInterpolation =>
|
||||
key &&
|
||||
value &&
|
||||
css`
|
||||
${key}: ${value};
|
||||
`;
|
||||
|
||||
interface IFlexProps {
|
||||
flexDirection?: string; // Need to replace this with exact css props. Not able to find any :(
|
||||
flex?: number | string;
|
||||
}
|
||||
export const Flex = ({
|
||||
flexDirection,
|
||||
flex,
|
||||
}: IFlexProps): FlattenSimpleInterpolation => css`
|
||||
${cssProperty('flex-direction', flexDirection)}
|
||||
${cssProperty('flex', flex)}
|
||||
`;
|
||||
|
||||
interface IDisplayProps {
|
||||
display?: string;
|
||||
}
|
||||
export const Display = ({
|
||||
display,
|
||||
}: IDisplayProps): FlattenSimpleInterpolation => css`
|
||||
${cssProperty('display', display)}
|
||||
`;
|
||||
|
||||
interface ISpacingProps {
|
||||
margin?: string;
|
||||
padding?: string;
|
||||
}
|
||||
export const Spacing = ({
|
||||
margin,
|
||||
padding,
|
||||
}: ISpacingProps): FlattenSimpleInterpolation => css`
|
||||
${cssProperty('margin', margin)}
|
||||
${cssProperty('padding', padding)}
|
||||
`;
|
||||
@@ -1,5 +0,0 @@
|
||||
export type TabLabelProps = {
|
||||
isDisabled: boolean;
|
||||
label: string;
|
||||
tooltipText?: string;
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import { TabLabelProps } from './TabLabel.interfaces';
|
||||
|
||||
function TabLabel({
|
||||
label,
|
||||
isDisabled,
|
||||
tooltipText,
|
||||
}: TabLabelProps): JSX.Element {
|
||||
const currentLabel = <span data-testid={`${label}`}>{label}</span>;
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
trigger="hover"
|
||||
autoAdjustOverflow
|
||||
placement="top"
|
||||
title={tooltipText}
|
||||
>
|
||||
{currentLabel}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return currentLabel;
|
||||
}
|
||||
|
||||
export default memo(TabLabel);
|
||||
@@ -1,5 +0,0 @@
|
||||
.tab-title {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Radio } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import { History, Table } from 'lucide-react';
|
||||
|
||||
import { ALERT_TABS } from '../constants';
|
||||
|
||||
import './Tabs.styles.scss';
|
||||
|
||||
export function Tabs(): JSX.Element {
|
||||
const [selectedTab, setSelectedTab] = useState('overview');
|
||||
|
||||
const handleTabChange = (e: RadioChangeEvent): void => {
|
||||
setSelectedTab(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Radio.Group className="tabs" onChange={handleTabChange} value={selectedTab}>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedTab === ALERT_TABS.OVERVIEW ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={ALERT_TABS.OVERVIEW}
|
||||
>
|
||||
<div className="tab-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={selectedTab === ALERT_TABS.HISTORY ? 'selected_view tab' : 'tab'}
|
||||
value={ALERT_TABS.HISTORY}
|
||||
>
|
||||
<div className="tab-title">
|
||||
<History size={14} />
|
||||
History
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs-and-filters {
|
||||
@include flex-center;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
.filters {
|
||||
@include flex-center;
|
||||
gap: 16px;
|
||||
.reset-button {
|
||||
@include flex-center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Filters } from 'components/AlertDetailsFilters/Filters';
|
||||
|
||||
import { Tabs } from './Tabs/Tabs';
|
||||
|
||||
import './TabsAndFilters.styles.scss';
|
||||
|
||||
function TabsAndFilters(): JSX.Element {
|
||||
return (
|
||||
<div className="tabs-and-filters">
|
||||
<Tabs />
|
||||
<Filters />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TabsAndFilters;
|
||||
@@ -1,5 +0,0 @@
|
||||
export const ALERT_TABS = {
|
||||
OVERVIEW: 'OVERVIEW',
|
||||
HISTORY: 'HISTORY',
|
||||
ACTIVITY: 'ACTIVITY',
|
||||
} as const;
|
||||
@@ -47,10 +47,16 @@ function TanStackCustomTableRow<TData>({
|
||||
const isActive = context?.isRowActive?.(rowData) ?? false;
|
||||
const extraClass = context?.getRowClassName?.(rowData) ?? '';
|
||||
const rowStyle = context?.getRowStyle?.(rowData);
|
||||
const enableAlternatingRowColors =
|
||||
context?.enableAlternatingRowColors ?? false;
|
||||
|
||||
const rowClassName = cx(
|
||||
tableStyles.tableRow,
|
||||
isActive && tableStyles.tableRowActive,
|
||||
enableAlternatingRowColors &&
|
||||
(item.row.index % 2 === 0
|
||||
? tableStyles.tableRowEven
|
||||
: tableStyles.tableRowOdd),
|
||||
extraClass,
|
||||
);
|
||||
|
||||
@@ -105,6 +111,12 @@ function areTableRowPropsEqual<TData>(
|
||||
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
prev.context?.enableAlternatingRowColors !==
|
||||
next.context?.enableAlternatingRowColors
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.context !== next.context) {
|
||||
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 0.3rem;
|
||||
padding: var(--tanstack-cell-padding-top, 0.3rem)
|
||||
var(--tanstack-cell-padding-right, 0.3rem)
|
||||
var(--tanstack-cell-padding-bottom, 0.3rem)
|
||||
var(--tanstack-cell-padding-left, 0.3rem);
|
||||
transform: translate3d(
|
||||
var(--tanstack-header-translate-x, 0px),
|
||||
var(--tanstack-header-translate-y, 0px),
|
||||
@@ -19,7 +22,17 @@
|
||||
}
|
||||
|
||||
border: none !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--tanstack-table-header-cell-bg,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&:first-child {
|
||||
background-color: var(
|
||||
--tanstack-first-column-header-bg,
|
||||
var(--tanstack-table-header-cell-bg, var(--l2-background))
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tanstackHeaderContent {
|
||||
@@ -61,7 +74,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: grab;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
|
||||
opacity: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -74,7 +87,7 @@
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
|
||||
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -82,8 +95,9 @@
|
||||
.tanstackColumnActionsContent {
|
||||
width: 140px;
|
||||
padding: 0;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
|
||||
border: 1px solid
|
||||
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -137,7 +151,7 @@
|
||||
}
|
||||
|
||||
.tanstackHeaderCell.isResizing .cursorColResize {
|
||||
background: var(--bg-robin-300);
|
||||
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
|
||||
}
|
||||
|
||||
.tanstackResizeHandleLine {
|
||||
@@ -147,7 +161,7 @@
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l2-background);
|
||||
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
@@ -155,13 +169,34 @@
|
||||
width 120ms ease;
|
||||
}
|
||||
|
||||
.tanstackHeaderCell:first-child .tanstackResizeHandleLine {
|
||||
background: var(
|
||||
--tanstack-first-column-header-bg,
|
||||
var(--tanstack-table-resize-handle-bg, var(--l2-background))
|
||||
);
|
||||
}
|
||||
|
||||
.cursorColResize:hover .tanstackResizeHandleLine {
|
||||
background: var(--l2-border);
|
||||
background: var(--tanstack-table-resize-handle-hover-bg, var(--l2-border));
|
||||
}
|
||||
|
||||
.tanstackHeaderCell:first-child
|
||||
.cursorColResize:hover
|
||||
.tanstackResizeHandleLine {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(
|
||||
--tanstack-first-column-header-bg,
|
||||
var(--tanstack-table-resize-handle-bg, var(--l2-background))
|
||||
)
|
||||
60%,
|
||||
black
|
||||
);
|
||||
}
|
||||
|
||||
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
|
||||
width: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -213,7 +248,12 @@
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&[data-sort-direction='asc'],
|
||||
&[data-sort-direction='desc'] {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.isSortable {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
|
||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||
import cx from 'classnames';
|
||||
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
|
||||
|
||||
import { SortState, TableColumnDef } from './types';
|
||||
|
||||
@@ -177,12 +177,17 @@ function TanStackHeaderRow<TData>({
|
||||
? column.header()
|
||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className={headerStyles.tanstackSortIndicator}>
|
||||
<span
|
||||
className={headerStyles.tanstackSortIndicator}
|
||||
data-sort-direction={currentSortDirection || 'none'}
|
||||
>
|
||||
{currentSortDirection === 'asc' ? (
|
||||
<ChevronUp size={SORT_ICON_SIZE} />
|
||||
<ArrowUp size={SORT_ICON_SIZE} />
|
||||
) : currentSortDirection === 'desc' ? (
|
||||
<ChevronDown size={SORT_ICON_SIZE} />
|
||||
) : null}
|
||||
<ArrowDown size={SORT_ICON_SIZE} />
|
||||
) : (
|
||||
<ArrowUpDown size={SORT_ICON_SIZE} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
.tanStackTable {
|
||||
--tanstack-cell-padding: var(--tanstack-cell-padding-override, 0.3rem);
|
||||
--tanstack-cell-padding-left: var(
|
||||
--tanstack-cell-padding-left-override,
|
||||
var(--tanstack-cell-padding)
|
||||
);
|
||||
--tanstack-cell-padding-right: var(
|
||||
--tanstack-cell-padding-right-override,
|
||||
var(--tanstack-cell-padding)
|
||||
);
|
||||
--tanstack-cell-padding-top: var(
|
||||
--tanstack-cell-padding-top-override,
|
||||
var(--tanstack-cell-padding)
|
||||
);
|
||||
--tanstack-cell-padding-bottom: var(
|
||||
--tanstack-cell-padding-bottom-override,
|
||||
var(--tanstack-cell-padding)
|
||||
);
|
||||
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@@ -26,7 +44,7 @@
|
||||
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: var(--l2-foreground);
|
||||
color: var(--tanstack-table-cell-color, var(--l2-foreground));
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -42,13 +60,35 @@
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: 0.3rem;
|
||||
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
|
||||
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
|
||||
height: var(--tanstack-table-row-height, auto);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: var(--l2-foreground);
|
||||
color: var(--tanstack-table-cell-color, var(--l2-foreground));
|
||||
background-color: var(--tanstack-table-cell-bg, transparent);
|
||||
|
||||
&:first-child {
|
||||
padding: var(
|
||||
--tanstack-cell-padding-top-first-column,
|
||||
var(--tanstack-cell-padding-top)
|
||||
)
|
||||
var(
|
||||
--tanstack-cell-padding-right-first-column,
|
||||
var(--tanstack-cell-padding-right)
|
||||
)
|
||||
var(
|
||||
--tanstack-cell-padding-bottom-first-column,
|
||||
var(--tanstack-cell-padding-bottom)
|
||||
)
|
||||
var(
|
||||
--tanstack-cell-padding-left-first-column,
|
||||
var(--tanstack-cell-padding-left)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
@@ -58,19 +98,69 @@
|
||||
|
||||
&:hover {
|
||||
.tableCell {
|
||||
background-color: var(--row-hover-bg) !important;
|
||||
background-color: var(
|
||||
--tanstack-table-row-hover-bg,
|
||||
var(--row-hover-bg)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.tableRowActive {
|
||||
.tableCell {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
background-color: var(
|
||||
--tanstack-table-row-active-bg,
|
||||
var(--row-active-bg)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.tableRowOdd {
|
||||
.tableCell {
|
||||
background-color: var(--tanstack-table-row-odd-bg, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&.tableRowEven {
|
||||
.tableCell {
|
||||
background-color: var(--tanstack-table-row-even-bg, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.tableCell:first-child {
|
||||
background-color: var(
|
||||
--tanstack-first-column-bg,
|
||||
var(--tanstack-table-cell-bg, transparent)
|
||||
);
|
||||
color: var(
|
||||
--tanstack-first-column-color,
|
||||
var(--tanstack-table-cell-color, var(--l2-foreground))
|
||||
);
|
||||
}
|
||||
|
||||
&.tableRowOdd .tableCell:first-child {
|
||||
background-color: var(
|
||||
--tanstack-first-column-odd-bg,
|
||||
var(
|
||||
--tanstack-first-column-bg,
|
||||
var(--tanstack-table-row-odd-bg, transparent)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
&.tableRowEven .tableCell:first-child {
|
||||
background-color: var(
|
||||
--tanstack-first-column-even-bg,
|
||||
var(
|
||||
--tanstack-first-column-bg,
|
||||
var(--tanstack-table-row-even-bg, transparent)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeaderCell {
|
||||
padding: 0.3rem;
|
||||
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
|
||||
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
|
||||
height: 36px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
@@ -78,20 +168,40 @@
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
// TODO: Remove this once background color (l1) is matching the actual background color of the page
|
||||
&[data-dark-mode='true'] {
|
||||
background: #0b0c0d;
|
||||
}
|
||||
|
||||
&[data-dark-mode='false'] {
|
||||
background: #fdfdfd;
|
||||
}
|
||||
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
|
||||
background: var(--tanstack-table-header-cell-bg, var(--l1-background));
|
||||
border-bottom: 1px solid var(--tanstack-table-header-border-color, transparent);
|
||||
}
|
||||
|
||||
.tableRowExpansion {
|
||||
display: table-row;
|
||||
|
||||
.tableCellExpansion {
|
||||
background-color: var(--tanstack-table-header-cell-bg, var(--l1-background));
|
||||
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
|
||||
}
|
||||
|
||||
& > td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& thead th:first-child {
|
||||
padding-left: var(
|
||||
--tanstack-expansion-first-col-padding-left,
|
||||
calc(var(--spacing-3) - 1px)
|
||||
);
|
||||
}
|
||||
|
||||
& tbody td:first-child {
|
||||
padding-left: var(
|
||||
--tanstack-expansion-first-col-padding-left,
|
||||
calc(var(--spacing-20) + var(--spacing-4))
|
||||
);
|
||||
}
|
||||
|
||||
:global(thead) {
|
||||
position: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tableCellExpansion {
|
||||
|
||||
@@ -90,6 +90,7 @@ function TanStackTableInner<TData>(
|
||||
skeletonRowCount = 10,
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
paginationClassname,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
@@ -112,9 +113,17 @@ function TanStackTableInner<TData>(
|
||||
testId,
|
||||
prefixPaginationContent,
|
||||
suffixPaginationContent,
|
||||
enableAlternatingRowColors,
|
||||
disableVirtualScroll,
|
||||
}: TanStackTableProps<TData>,
|
||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||
): JSX.Element {
|
||||
if (disableVirtualScroll && onEndReached) {
|
||||
throw new Error(
|
||||
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
|
||||
);
|
||||
}
|
||||
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -221,6 +230,15 @@ function TanStackTableInner<TData>(
|
||||
[getRowCanExpand],
|
||||
);
|
||||
|
||||
const isExpandEnabled = Boolean(renderExpandedRow);
|
||||
useEffect(() => {
|
||||
const hasExpanded =
|
||||
typeof expanded === 'boolean' ? expanded : Object.keys(expanded).length > 0;
|
||||
if (!isExpandEnabled && hasExpanded) {
|
||||
setExpanded({});
|
||||
}
|
||||
}, [isExpandEnabled, expanded, setExpanded]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: effectiveData,
|
||||
columns: tanstackColumns,
|
||||
@@ -229,7 +247,7 @@ function TanStackTableInner<TData>(
|
||||
columnResizeMode: 'onChange',
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
enableExpanding: Boolean(renderExpandedRow),
|
||||
enableExpanding: isExpandEnabled,
|
||||
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
|
||||
onColumnSizingChange: handleColumnSizingChange,
|
||||
onColumnVisibilityChange: noopColumnVisibility,
|
||||
@@ -333,6 +351,7 @@ function TanStackTableInner<TData>(
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
enableAlternatingRowColors,
|
||||
}),
|
||||
[
|
||||
getRowStyle,
|
||||
@@ -350,6 +369,7 @@ function TanStackTableInner<TData>(
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
enableAlternatingRowColors,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -500,7 +520,10 @@ function TanStackTableInner<TData>(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
|
||||
<div
|
||||
className={cx(viewStyles.tanstackTableViewWrapper, className)}
|
||||
data-has-group-by={(groupBy?.length || 0) > 0}
|
||||
>
|
||||
<TanStackTableStateProvider>
|
||||
<TableLoadingSync
|
||||
isLoading={isLoading}
|
||||
@@ -508,23 +531,53 @@ function TanStackTableInner<TData>(
|
||||
/>
|
||||
<ColumnVisibilitySync visibility={effectiveVisibility} />
|
||||
<TooltipProvider>
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||
className={virtuosoClassName}
|
||||
ref={virtuosoRef}
|
||||
{...restTableScrollerProps}
|
||||
data={flatItems}
|
||||
totalCount={flatItems.length}
|
||||
context={virtuosoContext}
|
||||
increaseViewportBy={INCREASE_VIEWPORT_BY}
|
||||
initialTopMostItemIndex={
|
||||
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
|
||||
}
|
||||
fixedHeaderContent={tableHeader}
|
||||
style={virtuosoTableStyle}
|
||||
components={virtuosoComponents}
|
||||
endReached={onEndReached ? handleEndReached : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{disableVirtualScroll ? (
|
||||
<div
|
||||
className={virtuosoClassName}
|
||||
{...restTableScrollerProps}
|
||||
data-testid={testId}
|
||||
>
|
||||
<table className={tableStyles.tanStackTable} style={virtuosoTableStyle}>
|
||||
<VirtuosoTableColGroup columns={effectiveColumns} table={table} />
|
||||
<thead>{tableHeader()}</thead>
|
||||
<tbody>
|
||||
{(isLoading && data.length === 0
|
||||
? flatItems.slice(0, skeletonRowCount)
|
||||
: flatItems
|
||||
).map((item, index) => (
|
||||
<TanStackCustomTableRow
|
||||
key={
|
||||
item.kind === 'expansion' ? `${item.row.id}-expansion` : item.row.id
|
||||
}
|
||||
item={item}
|
||||
context={virtuosoContext}
|
||||
data-index={index}
|
||||
data-item-index={index}
|
||||
data-known-size={0}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||
className={virtuosoClassName}
|
||||
ref={virtuosoRef}
|
||||
{...restTableScrollerProps}
|
||||
data={flatItems}
|
||||
totalCount={flatItems.length}
|
||||
context={virtuosoContext}
|
||||
increaseViewportBy={INCREASE_VIEWPORT_BY}
|
||||
initialTopMostItemIndex={
|
||||
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
|
||||
}
|
||||
fixedHeaderContent={tableHeader}
|
||||
style={virtuosoTableStyle}
|
||||
components={virtuosoComponents}
|
||||
endReached={onEndReached ? handleEndReached : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
)}
|
||||
{showInfiniteScrollLoader && (
|
||||
<div
|
||||
className={viewStyles.tanstackLoadingOverlay}
|
||||
@@ -534,7 +587,7 @@ function TanStackTableInner<TData>(
|
||||
</div>
|
||||
)}
|
||||
{showPagination && pagination && (
|
||||
<div className={viewStyles.paginationContainer}>
|
||||
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
|
||||
{prefixPaginationContent}
|
||||
<Pagination
|
||||
current={page}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
import { getColumnWidthStyle } from './utils';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
import styles from './TanStackTableSkeleton.module.scss';
|
||||
|
||||
type TanStackTableSkeletonProps<TData> = {
|
||||
columns: TableColumnDef<TData>[];
|
||||
rowCount: number;
|
||||
isDarkMode: boolean;
|
||||
columnSizing?: ColumnSizingState;
|
||||
};
|
||||
|
||||
export function TanStackTableSkeleton<TData>({
|
||||
columns,
|
||||
rowCount,
|
||||
isDarkMode,
|
||||
columnSizing,
|
||||
}: TanStackTableSkeletonProps<TData>): JSX.Element {
|
||||
const rows = useMemo(
|
||||
() => Array.from({ length: rowCount }, (_, i) => i),
|
||||
[rowCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<table className={tableStyles.tanStackTable}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={getColumnWidthStyle(
|
||||
column,
|
||||
columnSizing?.[column.id],
|
||||
index === columns.length - 1,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
className={tableStyles.tableHeaderCell}
|
||||
data-dark-mode={isDarkMode}
|
||||
>
|
||||
{typeof column.header === 'function' ? (
|
||||
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((rowIndex) => (
|
||||
<tr key={rowIndex} className={tableStyles.tableRow}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.id} className={tableStyles.tableCell}>
|
||||
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,8 @@
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bg-slate-300) transparent;
|
||||
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-slate-300))
|
||||
transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
@@ -65,12 +66,12 @@
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
background: var(--tanstack-table-scrollbar-color, var(--bg-slate-300));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
background: var(--tanstack-table-scrollbar-hover-color, var(--bg-slate-200));
|
||||
}
|
||||
|
||||
&.cellTypographySmall {
|
||||
@@ -135,18 +136,25 @@
|
||||
z-index: 3;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
background: var(--tanstack-table-loading-overlay-bg, var(--l1-background));
|
||||
box-shadow: var(
|
||||
--tanstack-table-loading-overlay-shadow,
|
||||
0 2px 8px rgba(0, 0, 0, 0.15)
|
||||
);
|
||||
}
|
||||
|
||||
:global(.lightMode) .tanstackTableVirtuosoScroll {
|
||||
scrollbar-color: var(--bg-vanilla-300) transparent;
|
||||
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300))
|
||||
transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
background: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300));
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-100);
|
||||
background: var(
|
||||
--tanstack-table-scrollbar-hover-color,
|
||||
var(--bg-vanilla-100)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +389,101 @@ describe('TanStackTableView Integration', () => {
|
||||
// by checking the table renders without errors
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without errors when expanded state exists but expansion is disabled', async () => {
|
||||
// This tests that the table handles the case where URL has expanded state
|
||||
// but renderExpandedRow is undefined (expansion disabled).
|
||||
// The table's useEffect should reset expanded state automatically.
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
enableQueryParams: true,
|
||||
// renderExpandedRow is undefined - expansion disabled
|
||||
},
|
||||
queryParams: { expanded: '["1"]' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Table should render without any expanded rows
|
||||
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders expanded rows with unique keys in non-virtualized mode', async () => {
|
||||
// This tests that row and expansion items have unique keys to avoid
|
||||
// React's "duplicate key" warning when disableVirtualScroll is true
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
disableVirtualScroll: true,
|
||||
enableQueryParams: true,
|
||||
renderExpandedRow: (row) => (
|
||||
<div data-testid={`expanded-${row.id}`}>Expanded: {row.name}</div>
|
||||
),
|
||||
getRowCanExpand: () => true,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
queryParams: { expanded: '["1"]' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both the row and its expansion content should be rendered
|
||||
expect(screen.getByTestId('expanded-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expanded: Item 1')).toBeInTheDocument();
|
||||
|
||||
// Verify all 3 data rows plus 1 expansion row = 4 tr elements in tbody
|
||||
const tbody = screen.getByRole('table').querySelector('tbody');
|
||||
expect(tbody?.querySelectorAll('tr')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableVirtualScroll', () => {
|
||||
it('throws error when used with onEndReached', () => {
|
||||
expect(() => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
disableVirtualScroll: true,
|
||||
onEndReached: jest.fn(),
|
||||
},
|
||||
});
|
||||
}).toThrow(
|
||||
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders all rows without virtualization', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
disableVirtualScroll: true,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify table structure exists
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders column headers without virtualization', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
disableVirtualScroll: true,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ID')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('infinite scroll', () => {
|
||||
|
||||
@@ -138,7 +138,10 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
|
||||
it('reads initial page from URL params', () => {
|
||||
const wrapper = createNuqsWrapper({ page: '3' });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
|
||||
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
@@ -249,3 +252,294 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (selective URL mode — partial config object)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('syncs only page to URL when only page is configured', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams({ page: 'myPage' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Update page - should sync to URL
|
||||
act(() => {
|
||||
result.current.setPage(5);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(5);
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('myPage'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('5');
|
||||
|
||||
// Update limit - should stay local (not in URL)
|
||||
act(() => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.limit).toBe(100);
|
||||
const limitInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) => call[0].searchParams.get('limit') !== null,
|
||||
);
|
||||
expect(limitInUrl).toBe(false);
|
||||
|
||||
// Update orderBy - should stay local (not in URL)
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'test', order: 'asc' });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'test',
|
||||
order: 'asc',
|
||||
});
|
||||
const orderByInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) => call[0].searchParams.get('order_by') !== null,
|
||||
);
|
||||
expect(orderByInUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('syncs only orderBy to URL when only orderBy is configured', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams({ orderBy: 'mySort' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Update orderBy - should sync to URL
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
});
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('mySort'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
expect(JSON.parse(lastOrderBy!)).toStrictEqual({
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
// Update page - should stay local
|
||||
act(() => {
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
const pageInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) => call[0].searchParams.get('page') !== null,
|
||||
);
|
||||
expect(pageInUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('syncs only limit to URL when only limit is configured', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams({ limit: 'myLimit' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Update limit - should sync to URL
|
||||
act(() => {
|
||||
result.current.setLimit(25);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.limit).toBe(25);
|
||||
const lastLimit = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('myLimit'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastLimit).toBe('25');
|
||||
|
||||
// Update page - should stay local
|
||||
act(() => {
|
||||
result.current.setPage(2);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(2);
|
||||
const pageInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) => call[0].searchParams.get('page') !== null,
|
||||
);
|
||||
expect(pageInUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('syncs only expanded to URL when only expanded is configured', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(
|
||||
() => useTableParams({ expanded: 'myExpanded' }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Update expanded - should sync to URL
|
||||
act(() => {
|
||||
result.current.setExpanded({ 'row-1': true, 'row-2': true });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.expanded).toStrictEqual({
|
||||
'row-1': true,
|
||||
'row-2': true,
|
||||
});
|
||||
const lastExpanded = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('myExpanded'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastExpanded).toBeDefined();
|
||||
expect(JSON.parse(lastExpanded!)).toEqual(
|
||||
expect.arrayContaining(['row-1', 'row-2']),
|
||||
);
|
||||
|
||||
// Update page - should stay local
|
||||
act(() => {
|
||||
result.current.setPage(4);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(4);
|
||||
const pageInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) => call[0].searchParams.get('page') !== null,
|
||||
);
|
||||
expect(pageInUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('syncs page and orderBy to URL but keeps limit and expanded local', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(
|
||||
() => useTableParams({ page: 'p', orderBy: 'sort' }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Update limit and expanded first (should stay local)
|
||||
act(() => {
|
||||
result.current.setLimit(75);
|
||||
result.current.setExpanded({ 'row-5': true });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(75);
|
||||
expect(result.current.expanded).toStrictEqual({ 'row-5': true });
|
||||
|
||||
// Update page (should sync to URL)
|
||||
act(() => {
|
||||
result.current.setPage(2);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.page).toBe(2);
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('p'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('2');
|
||||
|
||||
// Update orderBy (should sync to URL, and resets page to default)
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'name', order: 'asc' });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.orderBy).toStrictEqual({
|
||||
columnName: 'name',
|
||||
order: 'asc',
|
||||
});
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('sort'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
|
||||
// limit should NOT be in URL
|
||||
const limitInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) =>
|
||||
call[0].searchParams.get('limit') !== null ||
|
||||
call[0].searchParams.get('myLimit') !== null,
|
||||
);
|
||||
expect(limitInUrl).toBe(false);
|
||||
|
||||
// expanded should NOT be in URL
|
||||
const expandedInUrl = onUrlUpdate.mock.calls.some(
|
||||
(call) =>
|
||||
call[0].searchParams.get('expanded') !== null ||
|
||||
call[0].searchParams.get('myExpanded') !== null,
|
||||
);
|
||||
expect(expandedInUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('reads initial values from URL for configured params only', () => {
|
||||
const wrapper = createNuqsWrapper({
|
||||
customPage: '7',
|
||||
limit: '999', // This should be ignored since limit is not configured
|
||||
});
|
||||
const { result } = renderHook(
|
||||
// Pass page default matching URL to prevent reset on mount
|
||||
() => useTableParams({ page: 'customPage' }, { page: 7 }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Page should come from URL
|
||||
expect(result.current.page).toBe(7);
|
||||
// Limit should be default (not from URL since it's not configured)
|
||||
expect(result.current.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('supports updater function for expanded state', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams({ expanded: 'exp' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Set initial expanded state
|
||||
act(() => {
|
||||
result.current.setExpanded({ 'row-1': true });
|
||||
});
|
||||
expect(result.current.expanded).toStrictEqual({ 'row-1': true });
|
||||
|
||||
// Use updater function to add another row
|
||||
act(() => {
|
||||
result.current.setExpanded((prev) => ({
|
||||
...(typeof prev === 'boolean' ? {} : prev),
|
||||
'row-2': true,
|
||||
}));
|
||||
});
|
||||
expect(result.current.expanded).toStrictEqual({
|
||||
'row-1': true,
|
||||
'row-2': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports updater function for local expanded state', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
|
||||
// Set initial expanded state
|
||||
act(() => {
|
||||
result.current.setExpanded({ 'row-a': true });
|
||||
});
|
||||
expect(result.current.expanded).toStrictEqual({ 'row-a': true });
|
||||
|
||||
// Use updater function
|
||||
act(() => {
|
||||
result.current.setExpanded((prev) => ({
|
||||
...(typeof prev === 'boolean' ? {} : prev),
|
||||
'row-b': true,
|
||||
}));
|
||||
});
|
||||
expect(result.current.expanded).toStrictEqual({
|
||||
'row-a': true,
|
||||
'row-b': true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,27 @@ export * from './useTableParams';
|
||||
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Disable virtual scroll — useful for nested tables inside expanded rows.
|
||||
* Virtual scroll requires a fixed height container, which is problematic for nested tables
|
||||
* that need dynamic height. Use `disableVirtualScroll` when rendering tables inside
|
||||
* `renderExpandedRow` to allow the nested table to grow based on content.
|
||||
* Note: Cannot be combined with `onEndReached` (infinite scroll requires virtualization).
|
||||
* ```tsx
|
||||
* // Parent table with expandable rows
|
||||
* <TanStackTable
|
||||
* data={parentData}
|
||||
* columns={parentColumns}
|
||||
* renderExpandedRow={(row) => (
|
||||
* // Nested table without virtualization — height adapts to content
|
||||
* <TanStackTable
|
||||
* data={row.children}
|
||||
* columns={childColumns}
|
||||
* disableVirtualScroll
|
||||
* />
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -107,6 +107,8 @@ export type TableRowContext<TData> = {
|
||||
columnOrderKey: string;
|
||||
/** Column visibility key for memo invalidation on visibility change */
|
||||
columnVisibilityKey: string;
|
||||
/** Enable alternating row background colors (zebra striping) */
|
||||
enableAlternatingRowColors?: boolean;
|
||||
};
|
||||
|
||||
export type PaginationProps = {
|
||||
@@ -116,10 +118,10 @@ export type PaginationProps = {
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
page: string;
|
||||
limit: string;
|
||||
orderBy: string;
|
||||
expanded: string;
|
||||
page?: string;
|
||||
limit?: string;
|
||||
orderBy?: string;
|
||||
expanded?: string;
|
||||
};
|
||||
|
||||
export type TanStackTableProps<TData> = {
|
||||
@@ -137,6 +139,7 @@ export type TanStackTableProps<TData> = {
|
||||
skeletonRowCount?: number;
|
||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
|
||||
pagination?: PaginationProps;
|
||||
paginationClassname?: string;
|
||||
onEndReached?: (index: number) => void;
|
||||
/** Function to get the unique key for a row (before duplicate handling).
|
||||
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
||||
@@ -176,6 +179,10 @@ export type TanStackTableProps<TData> = {
|
||||
prefixPaginationContent?: ReactNode;
|
||||
/** Content rendered after the pagination controls */
|
||||
suffixPaginationContent?: ReactNode;
|
||||
/** Enable alternating row background colors (zebra striping) */
|
||||
enableAlternatingRowColors?: boolean;
|
||||
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
|
||||
disableVirtualScroll?: boolean;
|
||||
};
|
||||
|
||||
export type TanStackTableHandle = TableVirtuosoHandle & {
|
||||
|
||||
@@ -8,6 +8,12 @@ import { SortState, TanstackTableQueryParamsConfig } from './types';
|
||||
const NUQS_OPTIONS = { history: 'push' as const };
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const URL_KEYS_DEFAULT = {
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
orderBy: 'order_by',
|
||||
expanded: 'expanded',
|
||||
} as const;
|
||||
|
||||
type Defaults = {
|
||||
page?: number;
|
||||
@@ -49,30 +55,49 @@ export function useTableParams(
|
||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
|
||||
defaults?: Defaults,
|
||||
): TableParamsResult {
|
||||
// Determine which params should sync to URL vs use local state
|
||||
const isObjectConfig = typeof enableQueryParams === 'object';
|
||||
const useUrlForPage =
|
||||
enableQueryParams === true ||
|
||||
typeof enableQueryParams === 'string' ||
|
||||
(isObjectConfig && enableQueryParams.page !== undefined);
|
||||
const useUrlForLimit =
|
||||
enableQueryParams === true ||
|
||||
typeof enableQueryParams === 'string' ||
|
||||
(isObjectConfig && enableQueryParams.limit !== undefined);
|
||||
const useUrlForOrderBy =
|
||||
enableQueryParams === true ||
|
||||
typeof enableQueryParams === 'string' ||
|
||||
(isObjectConfig && enableQueryParams.orderBy !== undefined);
|
||||
const useUrlForExpanded =
|
||||
enableQueryParams === true ||
|
||||
typeof enableQueryParams === 'string' ||
|
||||
(isObjectConfig && enableQueryParams.expanded !== undefined);
|
||||
|
||||
const pageQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_page`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.page
|
||||
: 'page';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
|
||||
: URL_KEYS_DEFAULT.page;
|
||||
const limitQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_limit`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.limit
|
||||
: 'limit';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
|
||||
: URL_KEYS_DEFAULT.limit;
|
||||
const orderByQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_order_by`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.orderBy
|
||||
: 'order_by';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
|
||||
: URL_KEYS_DEFAULT.orderBy;
|
||||
const expandedQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_expanded`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.expanded
|
||||
: 'expanded';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
|
||||
: URL_KEYS_DEFAULT.expanded;
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
@@ -149,45 +174,29 @@ export function useTableParams(
|
||||
|
||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const isEnabledQueryParams =
|
||||
typeof enableQueryParams === 'string' ||
|
||||
typeof enableQueryParams === 'object';
|
||||
|
||||
useEffect(() => {
|
||||
if (isEnabledQueryParams) {
|
||||
if (useUrlForPage) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}, [
|
||||
isEnabledQueryParams,
|
||||
useUrlForPage,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
setUrlPage,
|
||||
]);
|
||||
|
||||
if (enableQueryParams) {
|
||||
return {
|
||||
page: urlPage,
|
||||
limit: urlLimit,
|
||||
orderBy: urlOrderBy as SortState | null,
|
||||
expanded: urlExpanded,
|
||||
setPage: setUrlPage,
|
||||
setLimit: setUrlLimit,
|
||||
setOrderBy: setUrlOrderBy,
|
||||
setExpanded: setUrlExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
page: localPage,
|
||||
limit: localLimit,
|
||||
orderBy: localOrderBy,
|
||||
expanded: localExpanded,
|
||||
setPage: setLocalPage,
|
||||
setLimit: setLocalLimit,
|
||||
setOrderBy: setLocalOrderBy,
|
||||
setExpanded: handleSetLocalExpanded,
|
||||
page: useUrlForPage ? urlPage : localPage,
|
||||
limit: useUrlForLimit ? urlLimit : localLimit,
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ export const getColumnWidthStyle = <TData>(
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn) {
|
||||
return { width: '100%' };
|
||||
return {
|
||||
width: '100%',
|
||||
minWidth: persistedWidth ?? column?.width?.min,
|
||||
};
|
||||
}
|
||||
|
||||
const { width } = column;
|
||||
@@ -59,10 +62,19 @@ export const getColumnWidthStyle = <TData>(
|
||||
};
|
||||
};
|
||||
|
||||
const isSkeletonRow = (row: unknown): boolean => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');
|
||||
};
|
||||
|
||||
const buildAccessorFn = <TData>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
): ((row: TData) => unknown) => {
|
||||
return (row: TData): unknown => {
|
||||
// Skip accessor for skeleton rows to avoid errors with missing properties
|
||||
if (isSkeletonRow(row)) {
|
||||
return undefined;
|
||||
}
|
||||
if (colDef.accessorFn) {
|
||||
return colDef.accessorFn(row);
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface TextContainerProps {
|
||||
noButtonMargin?: boolean;
|
||||
}
|
||||
|
||||
export const TextContainer = styled.div<TextContainerProps>`
|
||||
display: flex;
|
||||
|
||||
> button {
|
||||
margin-left: ${({ noButtonMargin }): string =>
|
||||
noButtonMargin ? '0' : '0.5rem'}
|
||||
`;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ReactChild } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Space, Typography } from 'antd';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import { Container, LeftContainer, Logo } from './styles';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function WelcomeLeftContainer({
|
||||
version,
|
||||
children,
|
||||
}: WelcomeLeftContainerProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LeftContainer direction="vertical">
|
||||
<Space align="center">
|
||||
<Logo src={signozBrandLogoUrl} alt="logo" />
|
||||
<Title style={{ fontSize: '46px', margin: 0 }}>SigNoz</Title>
|
||||
</Space>
|
||||
<Typography>{t('monitor_signup')}</Typography>
|
||||
<Card
|
||||
style={{ width: 'max-content' }}
|
||||
bodyStyle={{ padding: '1px 8px', width: '100%' }}
|
||||
>
|
||||
SigNoz {version}
|
||||
</Card>
|
||||
</LeftContainer>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface WelcomeLeftContainerProps {
|
||||
version: string;
|
||||
children: ReactChild;
|
||||
}
|
||||
|
||||
export default WelcomeLeftContainer;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Space } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const LeftContainer = styled(Space)`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export const Logo = styled.img`
|
||||
width: 60px;
|
||||
`;
|
||||
|
||||
export const Container = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
`;
|
||||
@@ -87,6 +87,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { Drawer } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import ConversationView from '../ConversationView';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { 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>
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNewConversation}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</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,100 @@
|
||||
$radius: 4px;
|
||||
|
||||
.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: $radius;
|
||||
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, #6b7280);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: $radius;
|
||||
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(--primary, var(--bg-robin-500)) !important;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import AIAssistantIcon from '../components/AIAssistantIcon';
|
||||
import HistorySidebar from '../components/HistorySidebar';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { 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,
|
||||
);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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}>
|
||||
<AIAssistantIcon size={16} />
|
||||
<span>AI Assistant</span>
|
||||
<kbd className={styles.shortcut}>
|
||||
<span>⌘</span>
|
||||
<span>J</span>
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Tooltip 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>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className={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,62 @@
|
||||
$radius: 4px;
|
||||
|
||||
.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(--primary-400, #7c3aed);
|
||||
}
|
||||
}
|
||||
|
||||
.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: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, X } from '@signozhq/icons';
|
||||
|
||||
import AIAssistantIcon from '../components/AIAssistantIcon';
|
||||
import HistorySidebar from '../components/HistorySidebar';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { VariantContext } from '../VariantContext';
|
||||
|
||||
import styles from './AIAssistantPanel.module.scss';
|
||||
|
||||
export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
// When user picks a conversation from the list, close the sidebar
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
// ── Resize logic ──────────────────────────────────────────────────────────
|
||||
const [panelWidth, setPanelWidth] = useState(380);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartWidth = useRef(0);
|
||||
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartWidth.current = panelWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent): void => {
|
||||
// Panel is on the right; dragging left (lower clientX) increases width
|
||||
const delta = dragStartX.current - ev.clientX;
|
||||
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
|
||||
setPanelWidth(next);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
if (!isOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<AIAssistantIcon size={18} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle conversations"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</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(--primary, var(--bg-robin-500));
|
||||
color: #fff;
|
||||
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,44 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
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 (
|
||||
<Tooltip title="AI Assistant">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.trigger}
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Bot size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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, var(--l2-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, var(--l2-foreground));
|
||||
text-align: center;
|
||||
|
||||
&.compact {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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/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,
|
||||
});
|
||||
|
||||
if (isLoadingThread && 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user