mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-02 16:20:56 +01:00
Compare commits
4 Commits
keystone-p
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19d0c9a63 | ||
|
|
1ecc8b291f | ||
|
|
22b04fbce6 | ||
|
|
de0ce21534 |
2
.github/workflows/goci.yaml
vendored
2
.github/workflows/goci.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
run: go run ./scripts/semconv -check
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -262,10 +262,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
|
||||
@go run ./scripts/semconv
|
||||
|
||||
.PHONY: semconv-check
|
||||
semconv-check: ## Fail if the generated semantic-convention files are stale
|
||||
@go run ./scripts/semconv -check
|
||||
|
||||
.PHONY: gen-mocks
|
||||
gen-mocks:
|
||||
@echo ">> Generating mocks"
|
||||
|
||||
@@ -184,7 +184,6 @@ components:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
@@ -218,7 +217,6 @@ components:
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
@@ -260,7 +258,6 @@ components:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
@@ -271,7 +268,6 @@ components:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
@@ -350,7 +346,6 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
@@ -379,7 +374,6 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
@@ -457,7 +451,6 @@ components:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
nullable: true
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
@@ -471,7 +464,6 @@ components:
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
nullable: true
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
@@ -1771,15 +1763,12 @@ components:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
timestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- timestampMillis
|
||||
- data
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesAzureAccountConfig:
|
||||
properties:
|
||||
@@ -2024,8 +2013,6 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
syncState:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
|
||||
required:
|
||||
- account_id
|
||||
- cloud_account_id
|
||||
@@ -2035,7 +2022,6 @@ components:
|
||||
- providerAccountId
|
||||
- integrationConfig
|
||||
- removedAt
|
||||
- syncState
|
||||
type: object
|
||||
CloudintegrationtypesGettableServicesMetadata:
|
||||
properties:
|
||||
@@ -2135,9 +2121,6 @@ components:
|
||||
type: object
|
||||
providerAccountId:
|
||||
type: string
|
||||
syncedVersion:
|
||||
nullable: true
|
||||
type: integer
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
@@ -2150,18 +2133,6 @@ components:
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesRegionState:
|
||||
enum:
|
||||
- enabled
|
||||
- disabled
|
||||
type: string
|
||||
CloudintegrationtypesRegionSyncState:
|
||||
properties:
|
||||
state:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionState'
|
||||
required:
|
||||
- state
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
assets:
|
||||
@@ -2303,23 +2274,6 @@ components:
|
||||
metrics:
|
||||
type: boolean
|
||||
type: object
|
||||
CloudintegrationtypesSyncState:
|
||||
nullable: true
|
||||
properties:
|
||||
inSync:
|
||||
type: boolean
|
||||
regions:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesRegionSyncState'
|
||||
type: object
|
||||
version:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- version
|
||||
- inSync
|
||||
- regions
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAccount:
|
||||
properties:
|
||||
config:
|
||||
@@ -26677,7 +26631,10 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- tokenizer: []
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Updates my password
|
||||
tags:
|
||||
- users
|
||||
|
||||
@@ -118,10 +118,10 @@ router.Handle("/api/v1/service_accounts", handler.New(
|
||||
The pieces:
|
||||
|
||||
- **`CheckResources(handlerFn, roles...)`** — the resource-aware authorization wrapper from [pkg/http/middleware/authz.go](/pkg/http/middleware/authz.go). The role list is the community-edition fallback: which managed roles may call this route when per-resource checks are unavailable.
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyField(func(req *T) string)` / `BodyFields(func(req *T) []string)` reading the request body the resource middleware decoded into the route's `OpenAPIDef.Request` type `T`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`). A handler on such a route reads the same decoded value with `coretypes.BodyFromContext[T](r.Context())`.
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). When a side's ids come from a list extractor (`BodyJSONArray` or a custom `ResourceIDsExtractor`) and that list resolves to nothing at request time, there is nothing to link: the def resolves to no resources, so no check runs and no audit event is emitted (e.g. inviting a user with an empty `userRoles`). A single-id side (`OneID`) always resolves to exactly one id and fails closed when it is empty. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). If the target list is optional in the payload (e.g. `userRoles` at user creation), set `OptionalTargets`: when no target ids resolve, the attach is vacuous and the def is skipped entirely — without it, the empty-id contract would check collection-level access on the target. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
|
||||
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|
||||
|---|---|---|---|---|---|
|
||||
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
|
||||
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
|
||||
|
||||
### The generic layer
|
||||
|
||||
@@ -109,7 +109,7 @@ The functions, from the outside in:
|
||||
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
|
||||
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
|
||||
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
|
||||
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
|
||||
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
|
||||
|
||||
### A resolved key
|
||||
|
||||
|
||||
@@ -183,52 +183,32 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := cloudintegrationtypes.NewAccountFromStorable(storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
// The agent uses this response to clean up resources
|
||||
if account.RemovedAt != nil {
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
// update account with cloud provider account id and agent report (heartbeat)
|
||||
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
err = module.store.UpdateAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
// Get account as domain object for config access (enabled regions, etc.)
|
||||
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,7 +223,8 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, account, storedServices)
|
||||
// Delegate integration config building entirely to the provider module
|
||||
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,7 +234,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
account.ID.StringValue(),
|
||||
integrationConfig,
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,13 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -59,6 +58,7 @@ function App(): JSX.Element {
|
||||
isFetchingActiveLicense,
|
||||
activeLicenseFetchError,
|
||||
userFetchError,
|
||||
featureFlagsFetchError,
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
@@ -66,8 +66,6 @@ function App(): JSX.Element {
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const chatSupport = useChatSupport();
|
||||
|
||||
const { hostname } = window.location;
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
@@ -255,9 +253,7 @@ function App(): JSX.Element {
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// The bottom strip carries Support, so the floating bubble goes entirely.
|
||||
if (
|
||||
isSavedViewEnabled ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
@@ -267,32 +263,71 @@ function App(): JSX.Element {
|
||||
} else {
|
||||
window.Pylon?.('showChatBubble');
|
||||
}
|
||||
}, [pathname, isSavedViewEnabled]);
|
||||
}, [pathname]);
|
||||
|
||||
// Identity for the Pylon widget. Whether this user gets Pylon at all is
|
||||
// `useChatSupport`'s call — this only fills in who they are.
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
useEffect(() => {
|
||||
if (chatSupport !== ChatSupportState.Pylon) {
|
||||
return;
|
||||
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
|
||||
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
|
||||
// to something went wrong which would ideally need a reload.
|
||||
if (
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo
|
||||
) {
|
||||
let isChatSupportEnabled = false;
|
||||
let isPremiumSupportEnabled = false;
|
||||
if (featureFlags && featureFlags.length > 0) {
|
||||
isChatSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
|
||||
?.active || false;
|
||||
|
||||
isPremiumSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
|
||||
?.active || false;
|
||||
}
|
||||
const showAddCreditCardModal =
|
||||
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (
|
||||
isLoggedInState &&
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}, [chatSupport, user]);
|
||||
}, [
|
||||
isLoggedInState,
|
||||
user,
|
||||
pathname,
|
||||
trialInfo?.trialConvertedToSubscription,
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
activeLicense,
|
||||
trialInfo,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingUser && isCloudUser && user && user.email) {
|
||||
|
||||
@@ -104,9 +104,9 @@ export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[] | null;
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -125,9 +125,9 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[] | null;
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -166,19 +166,13 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
|
||||
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
@@ -245,16 +239,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
|
||||
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -277,7 +265,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
@@ -319,16 +307,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
|
||||
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -344,7 +326,7 @@ export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
@@ -441,16 +423,10 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
|
||||
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -458,7 +434,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
@@ -474,9 +450,9 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[] | null;
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -567,23 +543,17 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
|
||||
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
* @type object
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
@@ -3396,37 +3366,6 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
|
||||
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesRegionStateDTO {
|
||||
enabled = 'enabled',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export interface CloudintegrationtypesRegionSyncStateDTO {
|
||||
state: CloudintegrationtypesRegionStateDTO;
|
||||
}
|
||||
|
||||
export type CloudintegrationtypesSyncStateDTORegions = {
|
||||
[key: string]: CloudintegrationtypesRegionSyncStateDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesSyncStateDTO = {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
inSync: boolean;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
regions: CloudintegrationtypesSyncStateDTORegions;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
version: number;
|
||||
} | null;
|
||||
|
||||
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -3445,7 +3384,6 @@ export type CloudintegrationtypesAgentReportDTO = {
|
||||
* @type object,null
|
||||
*/
|
||||
data: CloudintegrationtypesAgentReportDTOData;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
@@ -3874,7 +3812,6 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
removedAt: string | null;
|
||||
syncState: CloudintegrationtypesSyncStateDTO | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceMetadataDTO {
|
||||
@@ -3945,10 +3882,6 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
* @type string
|
||||
*/
|
||||
providerAccountId?: string;
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
syncedVersion?: number | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
interface AddCreditCardModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddCreditCard?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to trial users who have not added a card, in place of chat support.
|
||||
* Submitting creates the subscription and opens the returned billing URL.
|
||||
*/
|
||||
function AddCreditCardModal({
|
||||
open,
|
||||
onClose,
|
||||
onAddCreditCard,
|
||||
}: AddCreditCardModalProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
onAddCreditCard?.();
|
||||
updateCreditCard({ url: getBaseUrl() });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={open}
|
||||
closable
|
||||
onCancel={onClose}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={onClose}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>.
|
||||
Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
|
||||
|
||||
export default AddCreditCardModal;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
const SUBSCRIPTIONS = 'http://localhost/api/v1/subscriptions';
|
||||
|
||||
export const createSubscriptionHandlers = [
|
||||
rest.post(SUBSCRIPTIONS, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
|
||||
),
|
||||
];
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import { createSubscriptionHandlers } from './AddCreditCardModal.stories.mocks';
|
||||
|
||||
import AddCreditCardModal from '../AddCreditCardModal';
|
||||
|
||||
/** Stands in for the surfaces that open it: the strip's Support button, and
|
||||
* `LaunchChatSupport` in the custom domain and onboarding flows. */
|
||||
function ModalFixture(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="open-add-credit-card"
|
||||
onClick={(): void => setOpen(true)}
|
||||
>
|
||||
Contact support
|
||||
</Button>
|
||||
<AddCreditCardModal open={open} onClose={(): void => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Add Credit Card Modal',
|
||||
component: ModalFixture,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 400 })],
|
||||
parameters: {
|
||||
msw: { handlers: createSubscriptionHandlers },
|
||||
},
|
||||
} satisfies Meta<typeof ModalFixture>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** What a trial user without a card sees in place of chat support. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('open-add-credit-card'),
|
||||
);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /Add Credit Card for Chat Support/i,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-support-gateway">
|
||||
@@ -28,16 +76,47 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddCreditCardModal
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={isAddCreditCardModalOpen}
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={(): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import './LaunchChatSupport.styles.scss';
|
||||
|
||||
@@ -33,6 +41,7 @@ function LaunchChatSupport({
|
||||
chatMessageDisabled = false,
|
||||
}: LaunchChatSupportProps): JSX.Element | null {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
trialInfo,
|
||||
featureFlags,
|
||||
@@ -110,12 +119,43 @@ function LaunchChatSupport({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `facing issues button`,
|
||||
page: pathname,
|
||||
...attributes,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
|
||||
@@ -135,11 +175,47 @@ function LaunchChatSupport({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<AddCreditCardModal
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={isAddCreditCardModalOpen}
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={handleAddCreditCard}
|
||||
/>
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,82 +1,32 @@
|
||||
// Code generated by scripts/semconv. DO NOT EDIT.
|
||||
|
||||
// An empty contexts/signals/applyToMetrics array places no constraint on
|
||||
// that axis.
|
||||
export type SemconvMember = {
|
||||
readonly name: string;
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
};
|
||||
|
||||
export type SemconvFamily = {
|
||||
readonly current: string;
|
||||
readonly old: readonly string[];
|
||||
readonly kind: 'attribute' | 'metric';
|
||||
readonly members: readonly SemconvMember[];
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
readonly valueMap: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
{
|
||||
current: 'container.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{
|
||||
name: 'container.cpu.utilization',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
current: 'db.system.name',
|
||||
old: ['db.system'],
|
||||
kind: 'attribute',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'deployment.environment.name',
|
||||
old: ['deployment.environment'],
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: ['attribute', 'resource'],
|
||||
signals: ['logs', 'metrics', 'traces'],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.node.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{
|
||||
name: 'k8s.node.cpu.utilization',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.pod.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{
|
||||
name: 'k8s.pod.cpu.utilization',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
},
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -75,38 +75,6 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// Error bubble: a subtle error-tinted callout replacing the default
|
||||
// assistant background, rendered when a turn fails.
|
||||
.bubble.error {
|
||||
.assistant & {
|
||||
background: var(--callout-error-background);
|
||||
border: 1px solid var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--callout-error-title);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
margin-top: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
// User-bubble row: pencil button sits to the LEFT of the bubble within
|
||||
// the right-aligned message line, so it visually "ends" at the bubble's
|
||||
// right edge while keeping the bubble in its original position.
|
||||
|
||||
@@ -2,10 +2,6 @@ import React, { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RotateCw, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import '../blocks';
|
||||
@@ -108,23 +104,18 @@ function renderGroup(group: RenderGroup): JSX.Element {
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
onRetry?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
onRetry,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const isUser = message.role === 'user';
|
||||
const isError = !isUser && Boolean(message.isError);
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
const showRetry =
|
||||
isError && message.retryAction === RetryActionDTO.manual && Boolean(onRetry);
|
||||
|
||||
// Recompute groups only when the blocks array identity changes — store
|
||||
// updates that don't touch this message's blocks should not re-render the
|
||||
@@ -147,7 +138,7 @@ export default function MessageBubble({
|
||||
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
|
||||
<div className={bodyClass}>
|
||||
<div className={styles.bubbleRow}>
|
||||
<div className={cx(styles.bubble, { [styles.error]: isError })}>
|
||||
<div className={styles.bubble}>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{message.attachments.map((att) => {
|
||||
@@ -170,11 +161,6 @@ export default function MessageBubble({
|
||||
|
||||
{isUser ? (
|
||||
<p className={styles.text}>{message.content}</p>
|
||||
) : isError ? (
|
||||
<div className={styles.errorContent}>
|
||||
<TriangleAlert size={14} className={styles.errorIcon} />
|
||||
<span className={styles.errorText}>{message.content}</span>
|
||||
</div>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{groups.map((g) => renderGroup(g))}
|
||||
@@ -197,21 +183,7 @@ export default function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRetry && (
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onRetry}
|
||||
testId={`ai-message-retry-${message.id}`}
|
||||
>
|
||||
<RotateCw size={12} />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isUser && !isError && !message.isRateLimitError && (
|
||||
{!isUser && !message.isRateLimitError && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { Message } from '../../../types';
|
||||
|
||||
// react-markdown + remark-gfm are ESM-only and pull a large untransformed
|
||||
// dependency chain into jest. The error-rendering path under test renders
|
||||
// plain text (no markdown), so stub them to keep the import graph loadable.
|
||||
jest.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children?: React.ReactNode }): React.ReactNode =>
|
||||
children,
|
||||
}));
|
||||
jest.mock('remark-gfm', () => ({
|
||||
__esModule: true,
|
||||
default: (): void => undefined,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import MessageBubble from '../MessageBubble';
|
||||
|
||||
function errorMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 'err-1',
|
||||
role: 'assistant',
|
||||
content: 'This conversation is still finishing a previous response.',
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.thread_busy,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const retryButton = (): HTMLElement | null =>
|
||||
screen.queryByRole('button', { name: /retry/i });
|
||||
|
||||
describe('MessageBubble — error rendering', () => {
|
||||
it('shows a Retry button for a manual error and invokes onRetry on click', async () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<MessageBubble message={errorMessage()} onRetry={onRetry} />);
|
||||
|
||||
// Error copy is rendered, and the feedback bar is suppressed on errors.
|
||||
expect(
|
||||
screen.getByText(/still finishing a previous response/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /copy message/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const button = retryButton();
|
||||
expect(button).toBeInTheDocument();
|
||||
await userEvent.click(button as HTMLElement);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides the Retry button when retryAction is none', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={errorMessage({ retryAction: RetryActionDTO.none })}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Retry button when retryAction is auto', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={errorMessage({ retryAction: RetryActionDTO.auto })}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Retry button when no onRetry handler is provided', () => {
|
||||
render(<MessageBubble message={errorMessage()} />);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,6 @@ export default function VirtualizedMessages({
|
||||
const regenerateAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.regenerateAssistantMessage,
|
||||
);
|
||||
const retryAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.retryAssistantMessage,
|
||||
);
|
||||
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
@@ -88,14 +85,6 @@ export default function VirtualizedMessages({
|
||||
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback((): void => {
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.RetryClicked, { threadId });
|
||||
void retryAssistantMessage(conversationId);
|
||||
}, [conversationId, isStreaming, retryAssistantMessage, threadId]);
|
||||
|
||||
// Scroll all the way to the actual bottom — including the 64px of bottom
|
||||
// padding on the scroller — so the last bubble has visible breathing room
|
||||
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
|
||||
@@ -217,11 +206,6 @@ export default function VirtualizedMessages({
|
||||
? (): void => handleRegenerate(msg.id)
|
||||
: undefined
|
||||
}
|
||||
onRetry={
|
||||
msg.isError && isLastAssistant && !showStreamingSlot
|
||||
? handleRetry
|
||||
: undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,6 @@ export enum AIAssistantEvents {
|
||||
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
|
||||
CancelClicked = 'AI Assistant: Cancel clicked',
|
||||
RegenerateClicked = 'AI Assistant: Regenerate clicked',
|
||||
RetryClicked = 'AI Assistant: Retry clicked',
|
||||
MessageCopied = 'AI Assistant: Message copied',
|
||||
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
|
||||
ResourceOpened = 'AI Assistant: Resource opened',
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type { SSEEvent } from 'api/ai-assistant/chat';
|
||||
|
||||
import { useAIAssistantStore } from '../useAIAssistantStore';
|
||||
import type { Message } from '../../types';
|
||||
|
||||
// The store talks to the chat API only through these named exports. Mock the
|
||||
// whole module so we can drive the SSE stream + REST calls deterministically.
|
||||
jest.mock('api/ai-assistant/chat', () => ({
|
||||
__esModule: true,
|
||||
createThread: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
streamEvents: jest.fn(),
|
||||
approveExecution: jest.fn(),
|
||||
clarifyExecution: jest.fn(),
|
||||
regenerateMessage: jest.fn(),
|
||||
rejectExecution: jest.fn(),
|
||||
cancelExecution: jest.fn(),
|
||||
listThreads: jest.fn(),
|
||||
getThreadDetail: jest.fn(),
|
||||
updateThread: jest.fn(),
|
||||
submitFeedback: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
||||
const chat = jest.requireMock('api/ai-assistant/chat') as Record<
|
||||
string,
|
||||
jest.Mock
|
||||
>;
|
||||
|
||||
// Builds a single-use async stream from a fixed list of SSE events.
|
||||
async function* eventStream(events: SSEEvent[]): AsyncGenerator<SSEEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
function errorEvent(
|
||||
executionId: string,
|
||||
code: ErrorCodeDTO,
|
||||
retryAction: RetryActionDTO,
|
||||
): SSEEvent {
|
||||
return {
|
||||
type: 'error',
|
||||
executionId,
|
||||
error: { code, message: 'backend message' },
|
||||
retryAction,
|
||||
};
|
||||
}
|
||||
|
||||
function lastMessage(conversationId: string): Message {
|
||||
const conv = useAIAssistantStore.getState().conversations[conversationId];
|
||||
return conv.messages[conv.messages.length - 1];
|
||||
}
|
||||
|
||||
describe('useAIAssistantStore — streaming error handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAIAssistantStore.setState((s) => {
|
||||
s.conversations = {};
|
||||
s.streams = {};
|
||||
s.activeConversationId = null;
|
||||
});
|
||||
});
|
||||
|
||||
it('commits a manually-retryable error bubble with friendly copy and metadata', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-1');
|
||||
chat.sendMessage.mockResolvedValue('exec-1');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hello');
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-1'];
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.thread_busy,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
// Code-specific FE copy, not the raw backend message.
|
||||
expect(conv.messages[1].content).toContain(
|
||||
'still finishing a previous response',
|
||||
);
|
||||
});
|
||||
|
||||
it('replays the send on retry without re-pushing the user message', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-1');
|
||||
chat.sendMessage.mockResolvedValue('exec-1');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hello');
|
||||
|
||||
// The retry succeeds this time.
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec-1',
|
||||
messageId: 'm1',
|
||||
delta: 'Hi there',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await useAIAssistantStore.getState().retryAssistantMessage('thread-1');
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-1'];
|
||||
// Error bubble replaced by the assistant reply; the user message stays.
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Hi there',
|
||||
});
|
||||
expect(conv.messages[1].isError).toBeUndefined();
|
||||
// Thread already existed on retry; the user message was never re-sent as new.
|
||||
expect(chat.createThread).toHaveBeenCalledTimes(1);
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('silently retries auto-flagged errors, then downgrades to manual once spent', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-2');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
// Always auto-retryable: 1 initial attempt + MAX_AUTO_RETRIES (2) = 3 sends.
|
||||
chat.streamEvents.mockImplementation(() =>
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(lastMessage('thread-2')).toMatchObject({
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.internal_error,
|
||||
// Auto budget exhausted → presented as manual so a Retry button shows.
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('marks rate-limit errors and offers no retry', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-3');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.hourly_message_limit, RetryActionDTO.none),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
expect(lastMessage('thread-3')).toMatchObject({
|
||||
isError: true,
|
||||
isRateLimitError: true,
|
||||
retryAction: RetryActionDTO.none,
|
||||
});
|
||||
|
||||
// No retry thunk registered for a non-retryable error — retry is a no-op.
|
||||
const before =
|
||||
useAIAssistantStore.getState().conversations['thread-3'].messages.length;
|
||||
await useAIAssistantStore.getState().retryAssistantMessage('thread-3');
|
||||
expect(
|
||||
useAIAssistantStore.getState().conversations['thread-3'].messages,
|
||||
).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('recovers silently when an auto-flagged error succeeds on retry', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-4');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
chat.streamEvents
|
||||
.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
|
||||
]),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec',
|
||||
messageId: 'm1',
|
||||
delta: 'Recovered',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
// 1 initial attempt + 1 silent auto retry, then success — no error bubble.
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-4'];
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Recovered',
|
||||
});
|
||||
expect(conv.messages.some((m) => m.isError)).toBe(false);
|
||||
}, 10000);
|
||||
|
||||
it('replays the originating action on retry for a non-send error (approve)', async () => {
|
||||
chat.approveExecution.mockResolvedValue('exec-a');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-a', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
const convId = useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().approveAction(convId, 'approval-1');
|
||||
|
||||
expect(lastMessage(convId)).toMatchObject({
|
||||
isError: true,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
expect(chat.approveExecution).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry replays the approval (not a send) and succeeds this time.
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec-a',
|
||||
messageId: 'm1',
|
||||
delta: 'Approved',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await useAIAssistantStore.getState().retryAssistantMessage(convId);
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations[convId];
|
||||
expect(conv.messages).toHaveLength(1);
|
||||
expect(conv.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Approved',
|
||||
});
|
||||
expect(conv.messages[0].isError).toBeUndefined();
|
||||
expect(chat.approveExecution).toHaveBeenCalledTimes(2);
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTOBlocksAnyOfItem,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import {
|
||||
approveExecution,
|
||||
@@ -36,10 +35,7 @@ import {
|
||||
MessageBlock,
|
||||
MessageRole,
|
||||
} from '../types';
|
||||
import {
|
||||
resolveAssistantError,
|
||||
type AssistantErrorResolution,
|
||||
} from '../utils/resolveAssistantError';
|
||||
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types used by module-level helpers
|
||||
@@ -60,15 +56,6 @@ interface SSEStreamCtx {
|
||||
|
||||
const streamControllers = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
* Per-conversation retry thunks for the most recent failed turn. Populated by
|
||||
* `finalizeStreamingError` when the error is manually retryable; consumed by
|
||||
* the `retryAssistantMessage` action when the user clicks Retry. Transient
|
||||
* (not persisted) — it shares the in-memory lifetime of the error bubble it
|
||||
* backs, so a page reload drops both together.
|
||||
*/
|
||||
const retryRegistry = new Map<string, () => Promise<void>>();
|
||||
|
||||
function abortStream(conversationId: string): void {
|
||||
const ctrl = streamControllers.get(conversationId);
|
||||
if (ctrl) {
|
||||
@@ -210,7 +197,7 @@ function resetStreamingState(
|
||||
* Marker thrown by `runStreamingLoop` when an SSE event reports
|
||||
* `invalid_token`. Callers that own an originating action (sendMessage /
|
||||
* approve / clarify / regenerate) catch this and re-issue that action via
|
||||
* `streamWithRetry`; the retry's first REST call will 401, at which point
|
||||
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
|
||||
* the shared axios `interceptorRejected` rotates the access token and replays.
|
||||
*/
|
||||
class AuthExpiredError extends Error {
|
||||
@@ -220,50 +207,27 @@ class AuthExpiredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Capped silent re-attempts for backend-flagged transient (`auto`) errors. */
|
||||
const MAX_AUTO_RETRIES = 2;
|
||||
/** Backoff before each auto re-attempt, indexed by prior auto-retry count. */
|
||||
const AUTO_RETRY_BACKOFF_MS = [500, 1500];
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the SSE error carries the backend's `retryAction: 'auto'` flag. */
|
||||
function isAutoRetryableError(err: unknown): boolean {
|
||||
return (
|
||||
(err as { retryAction?: unknown } | undefined)?.retryAction ===
|
||||
RetryActionDTO.auto
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the originating action (e.g. sendMessage POST) and streams the
|
||||
* resulting execution, with two independent retry budgets:
|
||||
*
|
||||
* • Auth — on `AuthExpiredError` (SSE `invalid_token`), re-issues `start`
|
||||
* once. The retry's REST call 401s, the shared axios interceptor rotates
|
||||
* the access token + replays, and the new SSE picks up the rotated token.
|
||||
* Backend flags `invalid_token` as `manual`, so only a fresh execution helps.
|
||||
* • Auto — on an SSE error the backend flagged `retryAction: 'auto'`
|
||||
* (transient), silently re-issues `start` up to `MAX_AUTO_RETRIES` times
|
||||
* with backoff. Once exhausted the error propagates so the caller can
|
||||
* surface a manual Retry affordance.
|
||||
*
|
||||
* Both reset the stream state before re-attempting so a dead execution's
|
||||
* partial output isn't concatenated onto the retry.
|
||||
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
|
||||
* retry's REST call hits 401, the shared axios interceptor rotates the
|
||||
* access token and replays, and the new SSE picks up the rotated token from
|
||||
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
|
||||
* so the dead execution can't be resumed — only a fresh one helps.
|
||||
*/
|
||||
async function streamWithRetry(
|
||||
async function streamWithAuthRetry(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
set: StoreSetter,
|
||||
): Promise<void> {
|
||||
let authRetried = false;
|
||||
let autoRetries = 0;
|
||||
|
||||
for (;;) {
|
||||
for (let attempt = 0; attempt <= 1; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
// Drop any partial content/events from the previous attempt so the
|
||||
// retried execution's stream isn't concatenated with the dead one.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const executionId = await start();
|
||||
const ctrl = newStreamController(conversationId);
|
||||
@@ -278,28 +242,10 @@ async function streamWithRetry(
|
||||
return;
|
||||
} catch (err) {
|
||||
streamControllers.delete(conversationId);
|
||||
|
||||
if (err instanceof AuthExpiredError && !authRetried) {
|
||||
authRetried = true;
|
||||
} else if (isAutoRetryableError(err) && autoRetries < MAX_AUTO_RETRIES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await delay(AUTO_RETRY_BACKOFF_MS[autoRetries] ?? 1500);
|
||||
autoRetries += 1;
|
||||
} else {
|
||||
if (isAutoRetryableError(err)) {
|
||||
// Auto-retry budget spent — present the failure as manually
|
||||
// retryable so the caller surfaces a Retry button rather than
|
||||
// silently giving up.
|
||||
(err as { retryAction?: RetryActionDTO }).retryAction =
|
||||
RetryActionDTO.manual;
|
||||
}
|
||||
throw err;
|
||||
if (err instanceof AuthExpiredError && attempt < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop partial content/events from the failed attempt before retrying.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,7 +258,7 @@ async function streamWithRetry(
|
||||
*
|
||||
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
|
||||
* throws `AuthExpiredError` so the caller can re-issue the originating
|
||||
* action via `streamWithRetry`. We don't refresh here ourselves — the
|
||||
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
|
||||
* retry's REST call will 401 and the shared axios `interceptorRejected`
|
||||
* handles rotation + replay. Throws on any other `error` event — the
|
||||
* caller's catch block handles UI feedback.
|
||||
@@ -538,37 +484,20 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
|
||||
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a failed turn as an error message and removes the stream entry.
|
||||
* When the failure is manually retryable and a `retry` thunk is supplied, the
|
||||
* thunk is stashed in `retryRegistry` so the bubble's Retry button can replay
|
||||
* the originating action.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
resolution: AssistantErrorResolution,
|
||||
errorContent: string,
|
||||
set: StoreSetter,
|
||||
retry?: () => Promise<void>,
|
||||
isRateLimit = false,
|
||||
): void {
|
||||
const { message, code, retryAction, isRateLimit } = resolution;
|
||||
|
||||
if (retryAction === RetryActionDTO.manual && retry) {
|
||||
retryRegistry.set(conversationId, retry);
|
||||
} else {
|
||||
retryRegistry.delete(conversationId);
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
if (conv) {
|
||||
conv.messages.push({
|
||||
id: uuidv4(),
|
||||
role: 'assistant',
|
||||
content: message,
|
||||
content: errorContent,
|
||||
createdAt: Date.now(),
|
||||
isError: true,
|
||||
retryAction,
|
||||
...(code ? { errorCode: code } : {}),
|
||||
...(isRateLimit ? { isRateLimitError: true } : {}),
|
||||
});
|
||||
conv.updatedAt = Date.now();
|
||||
@@ -577,40 +506,6 @@ function finalizeStreamingError(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared streaming wrapper for actions that have no pre-stream setup beyond
|
||||
* resetting state (approve / clarify / regenerate). Streams the execution,
|
||||
* finalizes the message on success, and on failure resolves the error +
|
||||
* registers `retry` (the caller's own re-invocation) so the bubble can replay
|
||||
* it. `sendMessage` does not use this — it owns thread-creation/re-keying and
|
||||
* runs its own equivalent loop.
|
||||
*/
|
||||
async function streamAndFinalize(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
fallback: string,
|
||||
logLabel: string,
|
||||
set: StoreSetter,
|
||||
get: StoreGetter,
|
||||
retry: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await streamWithRetry(conversationId, start, set);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(logLabel, err);
|
||||
const resolution = resolveAssistantError(err, fallback);
|
||||
finalizeStreamingError(conversationId, resolution, set, retry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store interface
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -669,8 +564,6 @@ export interface AIAssistantStore {
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
) => Promise<void>;
|
||||
/** Replays the originating action for a manually-retryable error bubble. */
|
||||
retryAssistantMessage: (conversationId: string) => Promise<void>;
|
||||
submitMessageFeedback: (
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
@@ -984,7 +877,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
// there's no "originating action" to redo — reopening the
|
||||
// same dead executionId would just re-emit the failure.
|
||||
// Let the error bubble; the user can send a new message,
|
||||
// which will go through `streamWithRetry`.
|
||||
// which will go through `streamWithAuthRetry`.
|
||||
if (
|
||||
detail.activeExecutionId &&
|
||||
!streamControllers.has(threadId) &&
|
||||
@@ -1167,7 +1060,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
): Promise<void> => {
|
||||
const convId = get().activeConversationId;
|
||||
let convId = get().activeConversationId;
|
||||
if (!convId || !get().conversations[convId]) {
|
||||
return;
|
||||
}
|
||||
@@ -1200,75 +1093,63 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const conv = state.conversations[convId];
|
||||
const conv = state.conversations[convId!];
|
||||
conv.messages.push(userMessage);
|
||||
conv.updatedAt = Date.now();
|
||||
if (!conv.title && text.trim()) {
|
||||
conv.title = deriveTitle(text);
|
||||
}
|
||||
resetStreamingState(state, convId);
|
||||
resetStreamingState(state, convId!);
|
||||
});
|
||||
|
||||
// The full send — ensure a backend thread exists (re-keying the
|
||||
// optimistic client UUID on first send), POST the message, and
|
||||
// stream the reply. Defined as a closure so the error bubble's
|
||||
// Retry button can replay it without re-pushing the user message.
|
||||
const runSend = async (cid: string): Promise<void> => {
|
||||
let targetConvId = cid;
|
||||
try {
|
||||
let { threadId } = get().conversations[targetConvId];
|
||||
if (!threadId) {
|
||||
threadId = await createThread();
|
||||
// Re-key the conversation from client UUID to backend threadId
|
||||
// so fetchThreads won't create a duplicate entry later.
|
||||
const oldId = targetConvId;
|
||||
const newId = threadId;
|
||||
set((s) => {
|
||||
const conv = s.conversations[oldId];
|
||||
if (conv) {
|
||||
conv.id = newId;
|
||||
conv.threadId = newId;
|
||||
s.conversations[newId] = conv;
|
||||
delete s.conversations[oldId];
|
||||
if (s.activeConversationId === oldId) {
|
||||
s.activeConversationId = newId;
|
||||
}
|
||||
const stream = s.streams[oldId];
|
||||
if (stream) {
|
||||
s.streams[newId] = stream;
|
||||
delete s.streams[oldId];
|
||||
}
|
||||
try {
|
||||
let { threadId } = get().conversations[convId];
|
||||
if (!threadId) {
|
||||
threadId = await createThread();
|
||||
// Re-key the conversation from client UUID to backend threadId
|
||||
// so fetchThreads won't create a duplicate entry later.
|
||||
const oldId = convId;
|
||||
convId = threadId;
|
||||
set((s) => {
|
||||
const conv = s.conversations[oldId];
|
||||
if (conv) {
|
||||
conv.id = convId!;
|
||||
conv.threadId = convId!;
|
||||
s.conversations[convId!] = conv;
|
||||
delete s.conversations[oldId];
|
||||
if (s.activeConversationId === oldId) {
|
||||
s.activeConversationId = convId!;
|
||||
}
|
||||
});
|
||||
targetConvId = newId;
|
||||
}
|
||||
const tid = threadId;
|
||||
await streamWithRetry(
|
||||
targetConvId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
);
|
||||
|
||||
if (!hasPendingInput(targetConvId, get)) {
|
||||
finalizeStreamingMessage(targetConvId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const resolution = resolveAssistantError(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(targetConvId, resolution, set, () =>
|
||||
runSend(targetConvId),
|
||||
);
|
||||
const stream = s.streams[oldId];
|
||||
if (stream) {
|
||||
s.streams[convId!] = stream;
|
||||
delete s.streams[oldId];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const tid = threadId;
|
||||
await streamWithAuthRetry(
|
||||
convId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
);
|
||||
|
||||
await runSend(convId);
|
||||
if (!hasPendingInput(convId, get)) {
|
||||
finalizeStreamingMessage(convId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(convId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
approveAction: async (
|
||||
@@ -1286,17 +1167,26 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => approveExecution(approvalId),
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
'[AIAssistant] approveAction failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
await run();
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
rejectAction: async (
|
||||
@@ -1356,17 +1246,26 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => regenerateMessage(messageId),
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
'[AIAssistant] regenerateAssistantMessage failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
await run();
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
submitMessageFeedback: async (
|
||||
@@ -1413,42 +1312,26 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
conversationId,
|
||||
() => clarifyExecution(clarificationId, answers),
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
'[AIAssistant] submitClarification failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
await run();
|
||||
},
|
||||
|
||||
retryAssistantMessage: async (conversationId: string): Promise<void> => {
|
||||
const retry = retryRegistry.get(conversationId);
|
||||
if (!retry) {
|
||||
return;
|
||||
}
|
||||
retryRegistry.delete(conversationId);
|
||||
|
||||
// Drop the trailing error bubble we're retrying from and reset the
|
||||
// stream so the in-progress retry renders immediately. The retry
|
||||
// thunk replays the originating action without re-pushing the
|
||||
// user's message.
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
if (conv) {
|
||||
const last = conv.messages[conv.messages.length - 1];
|
||||
if (last?.isError) {
|
||||
conv.messages.pop();
|
||||
}
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
|
||||
await retry();
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ClarificationEventDTO,
|
||||
ErrorCodeDTO,
|
||||
FeedbackRatingDTO,
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
/** Client-only file attachment — no API equivalent (uploads happen via data URLs). */
|
||||
@@ -93,18 +91,6 @@ export interface Message {
|
||||
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
|
||||
*/
|
||||
isRateLimitError?: boolean;
|
||||
/**
|
||||
* Marks an assistant message that represents a failed turn. Drives the
|
||||
* error styling and replaces the feedback bar with a retry affordance.
|
||||
*/
|
||||
isError?: boolean;
|
||||
/** Known backend error code for the failure, when recognised. */
|
||||
errorCode?: ErrorCodeDTO;
|
||||
/**
|
||||
* Retry semantics for a failed turn — `manual` renders an inline Retry
|
||||
* button on the error bubble; `none`/`auto` render no button.
|
||||
*/
|
||||
retryAction?: RetryActionDTO;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantError } from '../resolveAssistantError';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
function restError(status: number, code: string, message: string): AxiosError {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status,
|
||||
data: { error: { code, message } },
|
||||
} as AxiosError['response'];
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('resolveAssistantError', () => {
|
||||
describe('message resolution', () => {
|
||||
it('prefers code-specific FE copy over the backend message', () => {
|
||||
const err = restError(409, ErrorCodeDTO.thread_busy, 'raw backend phrasing');
|
||||
|
||||
const { message } = resolveAssistantError(err, FALLBACK);
|
||||
expect(message).toBe(
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through to the backend message for a known code without FE copy', () => {
|
||||
const err = restError(
|
||||
400,
|
||||
ErrorCodeDTO.message_not_found,
|
||||
'No such message exists.',
|
||||
);
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: 'No such message exists.',
|
||||
code: ErrorCodeDTO.message_not_found,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the error code is not in ErrorCodeDTO', () => {
|
||||
const err = restError(400, 'future_unknown_code', 'Backend-only message');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
code: undefined,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('marks HTTP 429 responses as rate limited and non-retryable', () => {
|
||||
const err = restError(
|
||||
429,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
'Hourly limit reached.',
|
||||
);
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: "You've reached the hourly message limit. Please try again later.",
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats known SSE rate-limit codes as rate limited', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
const res = resolveAssistantError(err, FALLBACK);
|
||||
expect(res.isRateLimit).toBe(true);
|
||||
expect(res.retryAction).toBe(RetryActionDTO.none);
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when the code is unknown', () => {
|
||||
const err = restError(429, 'future_unknown_code', 'Too many requests');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
code: undefined,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryAction resolution', () => {
|
||||
it('honours an explicit retryAction from an SSE error event', () => {
|
||||
const err = Object.assign(new Error('Transient hiccup'), {
|
||||
code: ErrorCodeDTO.internal_error,
|
||||
retryAction: RetryActionDTO.auto,
|
||||
});
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.auto,
|
||||
);
|
||||
});
|
||||
|
||||
it('forces none for non-retryable permission errors', () => {
|
||||
const err = restError(403, ErrorCodeDTO.permission_denied, 'forbidden');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.none,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for 409 conflicts', () => {
|
||||
const err = restError(409, ErrorCodeDTO.thread_has_active_execution, 'busy');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for 5xx responses', () => {
|
||||
const err = restError(503, 'future_unknown_code', 'unavailable');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for network failures with no response', () => {
|
||||
const err = new AxiosError('Network Error');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives none for other 4xx responses', () => {
|
||||
const err = restError(400, 'future_unknown_code', 'bad request');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.none,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to manual for non-Axios errors with no code', () => {
|
||||
expect(resolveAssistantError(new Error('boom'), FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
describe('resolveAssistantErrorMessage', () => {
|
||||
it('returns backend message for a known error code', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.thread_busy,
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when error code is not in ErrorCodeDTO', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Backend-only message',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks HTTP 429 responses as rate limited', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
message: 'Hourly limit reached.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Hourly limit reached.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses backend message for known SSE rate-limit error codes', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Daily token limit exceeded.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when error code is unknown', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
/** User-facing copy: code-specific FE copy → backend message → caller fallback. */
|
||||
message: string;
|
||||
/** Known backend error code, when one we recognise was supplied. */
|
||||
code?: ErrorCodeDTO;
|
||||
/**
|
||||
* Whether/how the failed action may be retried:
|
||||
* • `auto` — transient; the caller may silently re-attempt (capped).
|
||||
* • `manual` — surface a Retry affordance to the user.
|
||||
* • `none` — retrying would re-fail deterministically; offer nothing.
|
||||
*/
|
||||
retryAction: RetryActionDTO;
|
||||
/** Quota/limit error — callers hide the retry + feedback bar (retrying just re-limits). */
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
/** Quota/limit codes — surfaced as rate-limit errors (no retry, feedback bar hidden). */
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Codes whose retry would re-fail deterministically — permission/config/validation
|
||||
* failures. These force `retryAction: none` regardless of HTTP status.
|
||||
*/
|
||||
const NON_RETRYABLE_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.permission_denied,
|
||||
ErrorCodeDTO.user_disabled,
|
||||
ErrorCodeDTO.org_disabled,
|
||||
ErrorCodeDTO.validation_error,
|
||||
ErrorCodeDTO.invalid_content_length,
|
||||
ErrorCodeDTO.invalid_fork_target,
|
||||
ErrorCodeDTO.missing_signoz_url,
|
||||
ErrorCodeDTO.invalid_signoz_url,
|
||||
ErrorCodeDTO.region_not_configured,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Code-specific, user-friendly copy. Takes precedence over the backend's raw
|
||||
* `error.message` so the user sees an actionable, consistent sentence rather
|
||||
* than internal phrasing. Codes absent here fall through to the backend message.
|
||||
*/
|
||||
const ERROR_CODE_COPY: Partial<Record<ErrorCodeDTO, string>> = {
|
||||
[ErrorCodeDTO.permission_denied]:
|
||||
"You don't have permission to do that. Contact your workspace admin if you think this is a mistake.",
|
||||
[ErrorCodeDTO.user_disabled]:
|
||||
'Your access to the AI assistant has been disabled. Contact your workspace admin to re-enable it.',
|
||||
[ErrorCodeDTO.org_disabled]:
|
||||
'The AI assistant is disabled for your organisation. An admin can enable it in settings.',
|
||||
[ErrorCodeDTO.thread_busy]:
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
[ErrorCodeDTO.thread_has_active_execution]:
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
[ErrorCodeDTO.hourly_message_limit]:
|
||||
"You've reached the hourly message limit. Please try again later.",
|
||||
[ErrorCodeDTO.daily_message_limit]:
|
||||
"You've reached the daily message limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.daily_token_limit]:
|
||||
"You've reached today's usage limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.daily_cost_limit]:
|
||||
"You've reached today's usage limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.budget_exceeded]:
|
||||
"You've reached your usage budget. Contact your workspace admin to raise it.",
|
||||
[ErrorCodeDTO.thread_message_limit]:
|
||||
'This conversation has reached its length limit. Start a new conversation to continue.',
|
||||
[ErrorCodeDTO.connection_limit_exceeded]:
|
||||
'Too many active conversations right now. Close one and try again.',
|
||||
[ErrorCodeDTO.max_turns_exceeded]:
|
||||
'The assistant reached the maximum number of steps for this request. Try rephrasing or breaking it into smaller asks.',
|
||||
[ErrorCodeDTO.region_unreachable]:
|
||||
"Couldn't reach your region's services. Please try again in a moment.",
|
||||
[ErrorCodeDTO.region_not_configured]:
|
||||
'No region is configured for the AI assistant yet. An admin can set this up in settings.',
|
||||
[ErrorCodeDTO.mcp_unavailable]:
|
||||
'A required service is temporarily unavailable. Please try again shortly.',
|
||||
[ErrorCodeDTO.sandbox_unavailable]:
|
||||
'The execution environment is temporarily unavailable. Please try again shortly.',
|
||||
[ErrorCodeDTO.internal_error]:
|
||||
'Something went wrong on our end. Please try again.',
|
||||
};
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryActionDTO(value: unknown): value is RetryActionDTO {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(Object.values(RetryActionDTO) as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the structured error body out of either an Axios REST error or the
|
||||
* SSE error the streaming loop throws (a plain `Error` augmented with `code`).
|
||||
*/
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
function isRateLimit(code: ErrorCodeDTO | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
return code !== undefined && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how the failed action may be retried. The backend's explicit signal
|
||||
* (SSE `ErrorEventDTO.retryAction`) is authoritative; otherwise we derive it
|
||||
* from the rate-limit/non-retryable code sets and the HTTP status.
|
||||
*/
|
||||
function resolveRetryAction(
|
||||
err: unknown,
|
||||
code: ErrorCodeDTO | undefined,
|
||||
rateLimited: boolean,
|
||||
): RetryActionDTO {
|
||||
const explicit = (err as { retryAction?: unknown } | undefined)?.retryAction;
|
||||
if (isRetryActionDTO(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
if (rateLimited || (code !== undefined && NON_RETRYABLE_CODES.has(code))) {
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
|
||||
if (isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
// No response → network/timeout failure; retrying may well succeed.
|
||||
if (status === undefined || status === 408) {
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
if (status === 409 || status >= 500) {
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
// Other 4xx (validation, bad request) re-fail deterministically.
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
|
||||
// Non-Axios transport/parse error with no code — let the user retry.
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
|
||||
function resolveMessage(
|
||||
code: ErrorCodeDTO | undefined,
|
||||
body: ErrorBodyDTO | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (code !== undefined && ERROR_CODE_COPY[code]) {
|
||||
return ERROR_CODE_COPY[code] as string;
|
||||
}
|
||||
// Trust the backend's message only for codes we recognise — never surface
|
||||
// raw text for unknown codes (could be an internal stack trace).
|
||||
if (code !== undefined && body?.message.trim()) {
|
||||
return body.message.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single resolution point for both SSE and REST assistant errors. Maps the
|
||||
* error onto user-facing copy plus retry semantics, degrading gracefully for
|
||||
* unknown codes (falls back to `fallback` + a `manual` retry where sensible).
|
||||
*/
|
||||
export function resolveAssistantError(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const code = isErrorCodeDTO(body?.code) ? body?.code : undefined;
|
||||
const rateLimited = isRateLimit(code, err);
|
||||
|
||||
return {
|
||||
message: resolveMessage(code, body, fallback),
|
||||
code,
|
||||
retryAction: resolveRetryAction(err, code, rateLimited),
|
||||
isRateLimit: rateLimited,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
message: string;
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
function isRateLimitError(code: string | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
|
||||
* otherwise returns `fallback`.
|
||||
*/
|
||||
export function resolveAssistantErrorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const isRateLimit = isRateLimitError(body?.code, err);
|
||||
|
||||
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
|
||||
return {
|
||||
message: body.message.trim(),
|
||||
isRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useExceptionsStripInfo } from '../useExceptionsStripInfo';
|
||||
|
||||
describe('useExceptionsStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('shows the rows on the page against the total', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '25 of 500 exceptions' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still says n of m when the whole list fits on one page', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 42, totalCount: 42 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '42 of 42 exceptions',
|
||||
});
|
||||
});
|
||||
|
||||
it('says exception, not exceptions, when there is one', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 1, totalCount: 1 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 of 1 exception',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero before the counts land', () => {
|
||||
renderHook(() => useExceptionsStripInfo({ shownCount: 0, totalCount: 0 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 of 0 exceptions',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,6 @@ import { Exception, PayloadProps } from 'types/api/errors/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { FilterDropdownExtendsProps } from './types';
|
||||
import { useExceptionsStripInfo } from './useExceptionsStripInfo';
|
||||
import {
|
||||
extractFilterValues,
|
||||
getDefaultFilterValue,
|
||||
@@ -161,11 +160,6 @@ function AllErrors(): JSX.Element {
|
||||
},
|
||||
]);
|
||||
|
||||
useExceptionsStripInfo({
|
||||
shownCount: data?.payload?.length ?? 0,
|
||||
totalCount: errorCountResponse.data?.payload ?? 0,
|
||||
});
|
||||
|
||||
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetching);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface UseExceptionsStripInfoArgs {
|
||||
shownCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function useExceptionsStripInfo({
|
||||
shownCount,
|
||||
totalCount,
|
||||
}: UseExceptionsStripInfoArgs): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{
|
||||
kind: StripItemKind.Text,
|
||||
text: `${shownCount} of ${pluralize(totalCount, 'exception')}`,
|
||||
},
|
||||
],
|
||||
[shownCount, totalCount],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
|
||||
import { Events } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
@@ -51,7 +52,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import history from 'lib/history';
|
||||
@@ -103,6 +103,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
trialInfo,
|
||||
activeLicense,
|
||||
isFetchingActiveLicense,
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
userPreferences,
|
||||
isFetchingUserPreferences,
|
||||
updateChangelog,
|
||||
@@ -489,7 +492,42 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
const chatSupport = useChatSupport();
|
||||
const showAddCreditCardModal = useMemo(() => {
|
||||
if (
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo
|
||||
) {
|
||||
let isChatSupportEnabled = false;
|
||||
let isPremiumSupportEnabled = false;
|
||||
if (featureFlags && featureFlags.length > 0) {
|
||||
isChatSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
|
||||
?.active || false;
|
||||
|
||||
isPremiumSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
|
||||
?.active || false;
|
||||
}
|
||||
return (
|
||||
isLoggedIn &&
|
||||
!isPremiumSupportEnabled &&
|
||||
isChatSupportEnabled &&
|
||||
!trialInfo?.trialConvertedToSubscription &&
|
||||
isCloudUserVal
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
featureFlags,
|
||||
featureFlagsFetchError,
|
||||
isCloudUserVal,
|
||||
isFetchingFeatureFlags,
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
trialInfo,
|
||||
]);
|
||||
|
||||
// Listen for API warnings
|
||||
const handleWarning = (
|
||||
@@ -847,16 +885,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{chatSupport === ChatSupportState.NeedsCard && !isSavedViewEnabled && (
|
||||
<ChatSupportGateway />
|
||||
)}
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
{showChangelogModal && changelog && (
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
|
||||
<Toaster
|
||||
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
|
||||
/>
|
||||
<Toaster />
|
||||
</Layout>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.askNoz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.pulseDot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
animation: askNozDotPulse 1.5s ease-in-out infinite;
|
||||
transform: scale(0.8);
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@keyframes askNozDotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
|
||||
import styles from './AskNoz.module.scss';
|
||||
|
||||
/**
|
||||
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
|
||||
* modal instead; this is deliberately the drawer.
|
||||
*
|
||||
* Carries the header's pending badge: when Noz is blocked on the user
|
||||
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
|
||||
* hiding the header button would remove a notification rather than move it.
|
||||
*/
|
||||
function AskNoz(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
|
||||
const pendingUserInputCount = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
|
||||
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
|
||||
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
|
||||
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
|
||||
{showPendingBadge && (
|
||||
<span className={styles.badge} aria-hidden>
|
||||
<span className={styles.pulseDot}>
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="noz-wave"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
|
||||
} your response`
|
||||
: 'Ask Noz'
|
||||
}
|
||||
>
|
||||
Ask Noz
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AskNoz;
|
||||
@@ -1,133 +0,0 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
|
||||
typeof useIsAIAssistantEnabled
|
||||
>;
|
||||
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { useAIAssistantStore } = jest.requireMock(
|
||||
'container/AIAssistant/store/useAIAssistantStore',
|
||||
) as { useAIAssistantStore: jest.Mock };
|
||||
|
||||
/** The component reads the store through three separate selector calls. */
|
||||
function mockStore({
|
||||
isDrawerOpen = false,
|
||||
isModalOpen = false,
|
||||
pendingCount = 0,
|
||||
}: {
|
||||
isDrawerOpen?: boolean;
|
||||
isModalOpen?: boolean;
|
||||
pendingCount?: number;
|
||||
} = {}): void {
|
||||
const state = { isDrawerOpen, isModalOpen, streams: {} };
|
||||
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
|
||||
const picked = selector(state);
|
||||
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
|
||||
// so stand in the count we want to assert against.
|
||||
return typeof picked === 'number' ? pendingCount : picked;
|
||||
});
|
||||
}
|
||||
|
||||
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
|
||||
// wrapper and the button itself is reached by role — there is only ever one.
|
||||
const SLOT = 'bottom-strip-ask-noz';
|
||||
|
||||
describe('AskNoz', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnabled.mockReturnValue(true);
|
||||
mockStore();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders when the assistant is enabled', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when the assistant is disabled', () => {
|
||||
mockEnabled.mockReturnValue(false);
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing while the drawer is open', () => {
|
||||
mockStore({ isDrawerOpen: true });
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
|
||||
const { queryByTestId } = render(<AskNoz />, undefined, {
|
||||
initialRoute: '/ai-assistant/some-conversation-id',
|
||||
});
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it does', () => {
|
||||
it('opens the drawer on click', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
it('announces the count when Noz is waiting on the user', () => {
|
||||
mockStore({ pendingCount: 2 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 2 actions need your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the singular for one pending action', () => {
|
||||
mockStore({ pendingCount: 1 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 1 action needs your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is pending', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
|
||||
it('stays quiet while the modal is open, Noz is already on screen', () => {
|
||||
mockStore({ isModalOpen: true, pendingCount: 3 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,6 @@
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
--button-font-size: 12px;
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
@@ -25,21 +23,14 @@
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
gap: var(--spacing-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// separator b/w strip entities
|
||||
.left > *:not(:last-child)::after,
|
||||
.right > *:not(:last-child)::after {
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l2-border);
|
||||
margin-left: var(--spacing-4);
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import StripKeyValue from '../components/StripKeyValue/StripKeyValue';
|
||||
import StripTypography from '../components/StripTypography/StripTypography';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
import { type StripItem, StripItemKind } from '../types';
|
||||
|
||||
function renderItem(item: StripItem, index: number): JSX.Element {
|
||||
if (item.kind === StripItemKind.KeyValue) {
|
||||
return (
|
||||
<StripKeyValue
|
||||
key={index}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
prefix={item.prefix}
|
||||
tone={item.tone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<StripTypography key={index} prefix={item.prefix}>
|
||||
{item.text}
|
||||
</StripTypography>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftSlot(): JSX.Element | null {
|
||||
const { versionData } = useAppContext();
|
||||
const left = useBottomStripStore((state) => state.left);
|
||||
const ownerId = useBottomStripStore((state) => state.ownerId);
|
||||
|
||||
const version = versionData?.version?.trim();
|
||||
const versionNode = version ? (
|
||||
<StripTypography>{version}</StripTypography>
|
||||
) : null;
|
||||
|
||||
if (!left?.length) {
|
||||
return versionNode;
|
||||
}
|
||||
|
||||
return (
|
||||
// Keyed so a page whose items throw does not leave the boundary latched on
|
||||
// the fallback for every page after it.
|
||||
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
|
||||
{left.map(renderItem)}
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeftSlot;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
if (chatSupport === ChatSupportState.Unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
window.Pylon?.('show');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<MessageSquareText size={16} />}
|
||||
onClick={handleClick}
|
||||
testId="bottom-strip-support"
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportButton;
|
||||
@@ -1,81 +0,0 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockChatSupport = useChatSupport as jest.MockedFunction<
|
||||
typeof useChatSupport
|
||||
>;
|
||||
|
||||
const BUTTON = 'bottom-strip-support';
|
||||
const MODAL_TITLE = 'Add Credit Card for Chat Support';
|
||||
|
||||
describe('SupportButton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.Pylon = jest.fn() as never;
|
||||
});
|
||||
|
||||
describe('when Pylon is available', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
|
||||
|
||||
it('shows the button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the Pylon widget on click', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
const { getByTestId, queryByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user needs a card', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
|
||||
|
||||
it('shows the same button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the credit card modal on click, not Pylon', () => {
|
||||
const { getByTestId, getByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when support is unavailable', () => {
|
||||
beforeEach(() =>
|
||||
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
|
||||
);
|
||||
|
||||
it('renders nothing at all', () => {
|
||||
const { queryByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
import { useBottomStrip } from '../useBottomStrip';
|
||||
import { StripItemKind } from '../types';
|
||||
|
||||
/** Stands in for a page that puts something on the left of the strip. */
|
||||
function Page({ text }: { text: string }): null {
|
||||
useBottomStrip(useMemo(() => [{ kind: StripItemKind.Text, text }], [text]));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page whose icon throws when the strip renders it. `prefix` is the one place
|
||||
* the config still takes a node, so it is the only way a page can break it.
|
||||
*/
|
||||
function BoomPage(): null {
|
||||
useBottomStrip([{ kind: StripItemKind.Text, text: 'boom', prefix: <Boom /> }]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A page whose value changes without needing `rerender`. */
|
||||
function ChangingPage(): JSX.Element {
|
||||
const [count, setCount] = useState(600);
|
||||
|
||||
useBottomStrip(
|
||||
useMemo(
|
||||
() => [{ kind: StripItemKind.Text, text: `${count} traces` }],
|
||||
[count],
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(): void => setCount(42)}>
|
||||
change
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page node that blows up while the strip renders it. */
|
||||
function Boom(): JSX.Element {
|
||||
throw new Error('bad left node');
|
||||
}
|
||||
|
||||
const VERSION = 'v0.134.67';
|
||||
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
|
||||
const withVersion = { appContextOverrides: { versionData } };
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
// The store is module level, so it outlives each test.
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
@@ -81,96 +28,22 @@ describe('BottomStrip', () => {
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByText } = render(<BottomStrip />, undefined, {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText(version)).toBeInTheDocument();
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
const strip = getByTestId('bottom-strip');
|
||||
|
||||
expect(strip).toBeInTheDocument();
|
||||
expect(strip).toHaveTextContent('');
|
||||
});
|
||||
|
||||
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
|
||||
// re-renders the original tree. These drive change through state and through
|
||||
// separate trees instead, which the module-level store lets them share.
|
||||
describe('left slot', () => {
|
||||
it('shows what the page put there instead of the version', () => {
|
||||
render(<Page text="600 traces" />);
|
||||
const { getByText, queryByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
expect(queryByText(VERSION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version once the page is gone', () => {
|
||||
const page = render(<Page text="600 traces" />);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
page.unmount();
|
||||
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates when the page changes what it shows', () => {
|
||||
render(<ChangingPage />);
|
||||
const { getByText, getByRole } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'change' }));
|
||||
|
||||
expect(getByText('42 traces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version when the page node throws', () => {
|
||||
// React logs the caught error, which is noise here.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
render(<BoomPage />);
|
||||
const { getByTestId, getByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the new page value when the old page unmounts after it', () => {
|
||||
// Navigation order: the next page mounts before the last one unmounts,
|
||||
// so without the owner guard the outgoing page wipes the incoming value.
|
||||
const pageA = render(<Page text="page A" />);
|
||||
render(<Page text="page B" />);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
pageA.unmount();
|
||||
|
||||
expect(getByText('page B')).toBeInTheDocument();
|
||||
});
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// `:` is spaced like a word, per the Figma, so the gap here is a space width
|
||||
// rather than the wider gap between whole items.
|
||||
.keyValue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing-1);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { StripTone } from '../../types';
|
||||
|
||||
import styles from './StripKeyValue.module.scss';
|
||||
|
||||
export interface StripKeyValueProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
/** Leading icon. Same shape as `Button`'s. */
|
||||
prefix?: ReactNode;
|
||||
/** Tints the icon; the text stays neutral at every tone. */
|
||||
tone?: StripTone;
|
||||
}
|
||||
|
||||
/** A labelled number, rendered `Spans : 31`. */
|
||||
function StripKeyValue({
|
||||
label,
|
||||
value,
|
||||
prefix,
|
||||
tone,
|
||||
}: StripKeyValueProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.keyValue}>
|
||||
{prefix && (
|
||||
<span className={cx(styles.prefix, tone && styles[tone])} data-tone={tone}>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text as="span" className={styles.label}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" className={styles.colon}>
|
||||
:
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" className={styles.value}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
StripKeyValue.defaultProps = { prefix: undefined, tone: StripTone.Default };
|
||||
|
||||
export default StripKeyValue;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { StripTone } from '../../../types';
|
||||
|
||||
import StripKeyValue from '../StripKeyValue';
|
||||
|
||||
describe('StripKeyValue', () => {
|
||||
it('renders the label and the value around a colon it owns', () => {
|
||||
const { getByText, container } = render(
|
||||
<StripKeyValue label="Spans" value={31} />,
|
||||
);
|
||||
|
||||
expect(getByText('Spans')).toBeInTheDocument();
|
||||
expect(getByText('31')).toBeInTheDocument();
|
||||
// The consumer never builds the string, so the spacing cannot drift.
|
||||
expect(container.textContent).toBe('Spans:31');
|
||||
});
|
||||
|
||||
it('tints only the icon when the tone is set', () => {
|
||||
const { container } = render(
|
||||
<StripKeyValue
|
||||
label="Errors"
|
||||
value={4}
|
||||
tone={StripTone.Error}
|
||||
prefix={<svg data-testid="icon" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The tone lands on the icon, never on the text.
|
||||
expect(container.querySelector('[data-tone]')).toHaveAttribute(
|
||||
'data-tone',
|
||||
'error',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the icon untinted by default', () => {
|
||||
const { container } = render(
|
||||
<StripKeyValue label="Errors" value={0} prefix={<svg />} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-tone]')).toHaveAttribute(
|
||||
'data-tone',
|
||||
'default',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
.stripTypography {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './StripTypography.module.scss';
|
||||
|
||||
interface StripTypographyProps {
|
||||
children: ReactNode;
|
||||
/** Leading icon. Same shape as `Button`'s. */
|
||||
prefix?: ReactNode;
|
||||
}
|
||||
|
||||
/** A single phrase. A labelled number belongs in `StripKeyValue`. */
|
||||
function StripTypography({
|
||||
children,
|
||||
prefix,
|
||||
}: StripTypographyProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.stripTypography}>
|
||||
{prefix}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
StripTypography.defaultProps = { prefix: undefined };
|
||||
|
||||
export type { StripTypographyProps };
|
||||
export default StripTypography;
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import AskNoz from './AskNoz/AskNoz';
|
||||
import LeftSlot from './LeftSlot/LeftSlot';
|
||||
import SupportButton from './SupportButton/SupportButton';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
@@ -12,6 +9,9 @@ export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
@@ -28,12 +28,13 @@ function BottomStrip(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
<LeftSlot />
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<AskNoz />
|
||||
<SupportButton />
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { StripItem } from '../types';
|
||||
|
||||
interface BottomStripState {
|
||||
/** What the strip shows on the left, or null to fall back to the version. */
|
||||
left: StripItem[] | null;
|
||||
/** Which page owns the current value — see `clearLeft`. */
|
||||
ownerId: string | null;
|
||||
setLeft: (ownerId: string, left: StripItem[] | null) => void;
|
||||
clearLeft: (ownerId: string) => void;
|
||||
}
|
||||
|
||||
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
|
||||
left: null,
|
||||
ownerId: null,
|
||||
setLeft: (ownerId, left): void => set({ left, ownerId }),
|
||||
// Only the current owner may clear. On a plain route swap React runs the old
|
||||
// page's cleanup before the new page's effect, so this is moot, but two
|
||||
// consumers can be mounted at once (a page under a drawer): the one that set
|
||||
// last owns the slot, and the other unmounting must not wipe it.
|
||||
clearLeft: (ownerId): void => {
|
||||
if (get().ownerId === ownerId) {
|
||||
set({ left: null, ownerId: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export enum StripTone {
|
||||
Default = 'default',
|
||||
Warning = 'warning',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export enum StripItemKind {
|
||||
/** A phrase in the page's own words, e.g. "18 services". */
|
||||
Text = 'text',
|
||||
/** A labelled number, rendered `label : value`. */
|
||||
KeyValue = 'keyValue',
|
||||
}
|
||||
|
||||
/** What a page may put on the left of the strip. */
|
||||
export type StripItem =
|
||||
| {
|
||||
kind: StripItemKind.Text;
|
||||
text: string;
|
||||
prefix?: ReactNode;
|
||||
}
|
||||
| {
|
||||
kind: StripItemKind.KeyValue;
|
||||
/** Without a colon; the strip renders the separator. */
|
||||
label: string;
|
||||
value: string | number;
|
||||
prefix?: ReactNode;
|
||||
/** Tints the icon; the text stays neutral at every tone. */
|
||||
tone?: StripTone;
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useEffect, useId } from 'react';
|
||||
|
||||
import { useBottomStripStore } from './store/useBottomStripStore';
|
||||
import type { StripItem } from './types';
|
||||
|
||||
/**
|
||||
* Puts `items` in the bottom strip for as long as the calling page is mounted.
|
||||
* Pass null to show nothing and let the version through. The left is the only
|
||||
* side a page contributes to; the right is the strip's own.
|
||||
*
|
||||
* There is no refresh API by design: a page that refetches re-renders, which
|
||||
* produces new items, which re-runs this effect. Memoise the array where it is
|
||||
* built, or the store is written on every render.
|
||||
*/
|
||||
export function useBottomStrip(items: StripItem[] | null): void {
|
||||
const ownerId = useId();
|
||||
const setLeft = useBottomStripStore((state) => state.setLeft);
|
||||
const clearLeft = useBottomStripStore((state) => state.clearLeft);
|
||||
|
||||
useEffect(() => {
|
||||
setLeft(ownerId, items);
|
||||
|
||||
return (): void => clearLeft(ownerId);
|
||||
}, [items, ownerId, setLeft, clearLeft]);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useHomeStripInfo } from 'container/Home/useHomeStripInfo';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -65,8 +64,6 @@ const homeInterval = 30 * 60 * 1000;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function Home(): JSX.Element {
|
||||
useHomeStripInfo();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGetAlerts } from 'api/generated/services/alerts';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useHomeStripInfo } from '../useHomeStripInfo';
|
||||
|
||||
jest.mock('api/generated/services/alerts', () => ({
|
||||
useGetAlerts: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetAlerts = useGetAlerts as jest.Mock;
|
||||
|
||||
describe('useHomeStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('counts the firing alert instances', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
|
||||
|
||||
renderHook(() => useHomeStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '3 alerts firing' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says alert, not alerts, when only one is firing', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}] } });
|
||||
|
||||
renderHook(() => useHomeStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 alert firing',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero before the response lands', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: undefined });
|
||||
|
||||
renderHook(() => useHomeStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 alerts firing',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
|
||||
|
||||
const { unmount } = renderHook(() => useHomeStripInfo());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetAlerts } from 'api/generated/services/alerts';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
export function useHomeStripInfo(): void {
|
||||
// Firing instances, not rules, matching the triggered alerts page.
|
||||
const { data } = useGetAlerts();
|
||||
|
||||
const count = data?.data?.length ?? 0;
|
||||
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{ kind: StripItemKind.Text, text: `${pluralize(count, 'alert')} firing` },
|
||||
],
|
||||
[count],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const accountsResponse: ListAccounts200 = {
|
||||
agentReport: {
|
||||
timestampMillis: 1747114366214,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
providerAccountId: PROVIDER_ACCOUNT_ID,
|
||||
removedAt: null,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useAlertRulesStripInfo } from '../useAlertRulesStripInfo';
|
||||
|
||||
describe('useAlertRulesStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('shows the rows on the page against the total', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '15 of 17 rules' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still says n of m when the whole list fits on one page', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 17, totalCount: 17 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '17 of 17 rules',
|
||||
});
|
||||
});
|
||||
|
||||
it('says rule, not rules, when there is only one', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 1, totalCount: 1 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 of 1 rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero when nothing matched', () => {
|
||||
renderHook(() => useAlertRulesStripInfo({ shownCount: 0, totalCount: 12 }));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 of 12 rules',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
import type { AlertRule } from './types';
|
||||
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
|
||||
import { useAlertRulesData } from './useAlertRulesData';
|
||||
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
|
||||
|
||||
@@ -88,11 +87,6 @@ function ListAlertRules(): JSX.Element {
|
||||
return filteredRules.slice(start, start + limit);
|
||||
}, [filteredRules, page, limit]);
|
||||
|
||||
useAlertRulesStripInfo({
|
||||
shownCount: paginatedRules.length,
|
||||
totalCount: filteredRules.length,
|
||||
});
|
||||
|
||||
const columnsWithActions = useMemo(() => {
|
||||
if (!action) {
|
||||
return columns;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
interface UseAlertRulesStripInfoArgs {
|
||||
/** Rows on the current page, matching the table's own footer. */
|
||||
shownCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export function useAlertRulesStripInfo({
|
||||
shownCount,
|
||||
totalCount,
|
||||
}: UseAlertRulesStripInfoArgs): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{
|
||||
kind: StripItemKind.Text,
|
||||
text: `${shownCount} of ${pluralize(totalCount, 'rule')}`,
|
||||
},
|
||||
],
|
||||
[shownCount, totalCount],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -146,69 +146,13 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults the key to signoz.workspace.key.id', async () => {
|
||||
it('hardcodes the key and does not let it be edited', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the selector when another key is picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('wizard-value-input-logs-read'),
|
||||
'checkout',
|
||||
);
|
||||
await user.click(screen.getByTestId('wizard-key-select-logs-read'));
|
||||
await user.click(
|
||||
await screen.findByTestId('wizard-key-option-service.name-logs-read'),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/service.name/checkout',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/service.name/checkout'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects the key from a typed selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/deployment.environment/prod');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'deployment.environment',
|
||||
);
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
|
||||
'prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('hints when a typed selector uses an unsupported key', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/host.name/web-1');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('"host.name" is not a supported key.');
|
||||
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
|
||||
expect(keyInput).toHaveValue('signoz.workspace.key.id');
|
||||
expect(keyInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides Key field for query types that do not support key scoping', async () => {
|
||||
@@ -219,7 +163,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-key-select-logs-read'),
|
||||
screen.queryByTestId('wizard-key-input-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -341,18 +285,35 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
expect(screen.getByLabelText('Any value')).toBeChecked();
|
||||
});
|
||||
|
||||
it('restores the selected key in the selector once the value changes', async () => {
|
||||
it('keeps the key input hardcoded when the selector uses another key', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/host.name/web-1');
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('restores the hardcoded key in the selector once the value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
|
||||
|
||||
expect(selectorInput).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/web-12',
|
||||
'builder_query/signoz.workspace.key.id/frontend2',
|
||||
);
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -30,27 +30,17 @@ export const QUERY_TYPES: readonly QueryTypeOption[] = [
|
||||
|
||||
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
|
||||
|
||||
// mirrors telemetryGrantKeys in pkg/types/telemetrytypes/selector.go
|
||||
export const SUPPORTED_GRANT_KEYS: readonly string[] = [
|
||||
'signoz.workspace.key.id',
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'deployment.environment.name',
|
||||
];
|
||||
|
||||
export const DEFAULT_GRANT_KEY = 'signoz.workspace.key.id';
|
||||
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
|
||||
|
||||
export const ANY_RESOURCE_VALUE = '*';
|
||||
|
||||
export interface SelectorDraft {
|
||||
queryType: QueryTypeId;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ParsedSelector {
|
||||
queryType?: QueryTypeId;
|
||||
key?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
QUERY_TYPES,
|
||||
SUPPORTED_GRANT_KEYS,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
|
||||
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
|
||||
@@ -38,7 +38,6 @@ function TelemetrySelectorWizard({
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
grantKey,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource,
|
||||
@@ -47,7 +46,6 @@ function TelemetrySelectorWizard({
|
||||
canAdd,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleKeyChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
@@ -127,22 +125,12 @@ function TelemetrySelectorWizard({
|
||||
<Typography as="label" weight="medium">
|
||||
Key
|
||||
</Typography>
|
||||
<Select value={grantKey} onChange={handleKeyChange}>
|
||||
<SelectTrigger data-testid={`wizard-key-select-${testId}`}>
|
||||
<SelectValue>{grantKey}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent withPortal={false} className={styles.selectContent}>
|
||||
{SUPPORTED_GRANT_KEYS.map((supportedKey) => (
|
||||
<SelectItem
|
||||
key={supportedKey}
|
||||
value={supportedKey}
|
||||
testId={`wizard-key-option-${supportedKey}-${testId}`}
|
||||
>
|
||||
{supportedKey}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={SUPPORTED_GRANT_KEY}
|
||||
readOnly
|
||||
disabled
|
||||
testId={`wizard-key-input-${testId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
DEFAULT_GRANT_KEY,
|
||||
ParsedSelector,
|
||||
QUERY_TYPES,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
SelectorDraft,
|
||||
SelectorValidation,
|
||||
SUPPORTED_GRANT_KEYS,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
|
||||
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
|
||||
@@ -38,10 +37,6 @@ export function isAnyResourceValue(value: string): boolean {
|
||||
return value.trim() === ANY_RESOURCE_VALUE;
|
||||
}
|
||||
|
||||
export function isSupportedGrantKey(key: string): boolean {
|
||||
return SUPPORTED_GRANT_KEYS.includes(key);
|
||||
}
|
||||
|
||||
function splitSelector(selector: string): string[] {
|
||||
const parts = selector.split('/');
|
||||
|
||||
@@ -52,18 +47,14 @@ function splitSelector(selector: string): string[] {
|
||||
return [parts[0], parts[1], parts.slice(2).join('/')];
|
||||
}
|
||||
|
||||
export function buildSelector({
|
||||
queryType,
|
||||
key,
|
||||
value,
|
||||
}: SelectorDraft): string {
|
||||
export function buildSelector({ queryType, value }: SelectorDraft): string {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (!supportsKeyScoping(queryType) || !trimmedValue) {
|
||||
return `${queryType}/${ANY_RESOURCE_VALUE}`;
|
||||
}
|
||||
|
||||
return `${queryType}/${key}/${trimmedValue}`;
|
||||
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
|
||||
}
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
@@ -71,7 +62,6 @@ export function parseSelector(selector: string): ParsedSelector {
|
||||
|
||||
return {
|
||||
queryType: getQueryTypeOption(parts[0])?.id,
|
||||
key: parts.length >= 3 ? parts[1] : undefined,
|
||||
value: parts.length >= 3 ? parts[2] : '',
|
||||
};
|
||||
}
|
||||
@@ -111,9 +101,7 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/<key>/<value> with one of: ${SUPPORTED_GRANT_KEYS.join(
|
||||
', ',
|
||||
)}.`,
|
||||
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
@@ -140,15 +128,6 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
};
|
||||
}
|
||||
|
||||
if (!isSupportedGrantKey(key)) {
|
||||
return {
|
||||
message: `"${key}" is not a supported key. Use one of: ${SUPPORTED_GRANT_KEYS.join(
|
||||
', ',
|
||||
)}.`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Allow ${key}=${value} for ${option.label} queries.`,
|
||||
isError: false,
|
||||
@@ -156,5 +135,5 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
}
|
||||
|
||||
export function getDefaultSelector(queryType: QueryTypeId): string {
|
||||
return buildSelector({ queryType, key: DEFAULT_GRANT_KEY, value: '' });
|
||||
return buildSelector({ queryType, value: '' });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
DEFAULT_GRANT_KEY,
|
||||
DEFAULT_QUERY_TYPE,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
getDefaultSelector,
|
||||
getQueryTypeOption,
|
||||
isAnyResourceValue,
|
||||
isSupportedGrantKey,
|
||||
parseSelector,
|
||||
validateSelector,
|
||||
} from './TelemetrySelectorWizard.utils';
|
||||
@@ -26,7 +24,6 @@ interface UseTelemetrySelectorWizardResult {
|
||||
open: boolean;
|
||||
queryType: QueryTypeId;
|
||||
selectedQueryType: QueryTypeOption | undefined;
|
||||
grantKey: string;
|
||||
value: string;
|
||||
selector: string;
|
||||
isAnyResource: boolean;
|
||||
@@ -35,7 +32,6 @@ interface UseTelemetrySelectorWizardResult {
|
||||
canAdd: boolean;
|
||||
handleOpenChange: (nextOpen: boolean) => void;
|
||||
handleQueryTypeChange: (value: string | string[]) => void;
|
||||
handleKeyChange: (value: string | string[]) => void;
|
||||
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleAnyResourceChange: (checked: boolean) => void;
|
||||
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -48,7 +44,6 @@ function useTelemetrySelectorWizard({
|
||||
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
|
||||
const [grantKey, setGrantKey] = useState(DEFAULT_GRANT_KEY);
|
||||
const [value, setValue] = useState('');
|
||||
const [selector, setSelector] = useState(() =>
|
||||
getDefaultSelector(DEFAULT_QUERY_TYPE),
|
||||
@@ -63,17 +58,10 @@ function useTelemetrySelectorWizard({
|
||||
const validation = useMemo(() => validateSelector(selector), [selector]);
|
||||
|
||||
const applyDraft = useCallback(
|
||||
(nextQueryType: QueryTypeId, nextKey: string, nextValue: string): void => {
|
||||
(nextQueryType: QueryTypeId, nextValue: string): void => {
|
||||
setQueryType(nextQueryType);
|
||||
setGrantKey(nextKey);
|
||||
setValue(nextValue);
|
||||
setSelector(
|
||||
buildSelector({
|
||||
queryType: nextQueryType,
|
||||
key: nextKey,
|
||||
value: nextValue,
|
||||
}),
|
||||
);
|
||||
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -83,30 +71,23 @@ function useTelemetrySelectorWizard({
|
||||
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
|
||||
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
|
||||
|
||||
applyDraft(selected, grantKey, keepsValue ? value : '');
|
||||
applyDraft(selected, keepsValue ? value : '');
|
||||
},
|
||||
[applyDraft, grantKey, value],
|
||||
);
|
||||
|
||||
const handleKeyChange = useCallback(
|
||||
(next: string | string[]): void => {
|
||||
applyDraft(queryType, Array.isArray(next) ? next[0] : next, value);
|
||||
},
|
||||
[applyDraft, queryType, value],
|
||||
[applyDraft, value],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
applyDraft(queryType, grantKey, event.target.value);
|
||||
applyDraft(queryType, event.target.value);
|
||||
},
|
||||
[applyDraft, queryType, grantKey],
|
||||
[applyDraft, queryType],
|
||||
);
|
||||
|
||||
const handleAnyResourceChange = useCallback(
|
||||
(checked: boolean): void => {
|
||||
applyDraft(queryType, grantKey, checked ? ANY_RESOURCE_VALUE : '');
|
||||
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
|
||||
},
|
||||
[applyDraft, queryType, grantKey],
|
||||
[applyDraft, queryType],
|
||||
);
|
||||
|
||||
const handleSelectorChange = useCallback(
|
||||
@@ -118,9 +99,6 @@ function useTelemetrySelectorWizard({
|
||||
if (parsed.queryType) {
|
||||
setQueryType(parsed.queryType);
|
||||
}
|
||||
if (parsed.key && isSupportedGrantKey(parsed.key)) {
|
||||
setGrantKey(parsed.key);
|
||||
}
|
||||
setValue(parsed.value);
|
||||
},
|
||||
[],
|
||||
@@ -131,7 +109,6 @@ function useTelemetrySelectorWizard({
|
||||
|
||||
if (!nextOpen) {
|
||||
setQueryType(DEFAULT_QUERY_TYPE);
|
||||
setGrantKey(DEFAULT_GRANT_KEY);
|
||||
setValue('');
|
||||
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
|
||||
}
|
||||
@@ -161,7 +138,6 @@ function useTelemetrySelectorWizard({
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
grantKey,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource: isAnyResourceValue(value),
|
||||
@@ -170,7 +146,6 @@ function useTelemetrySelectorWizard({
|
||||
canAdd: !validation.isError,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleKeyChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getTotalRPS } from 'utils/services';
|
||||
|
||||
import { getColumns } from '../Columns/ServiceColumn';
|
||||
import { ServiceMetricsTableProps } from '../types';
|
||||
import { useServicesStripInfo } from '../useServicesStripInfo';
|
||||
import { getServiceListFromQuery } from '../utils';
|
||||
|
||||
function ServiceMetricTable({
|
||||
@@ -68,8 +67,6 @@ function ServiceMetricTable({
|
||||
[isLoading, queries, topLevelOperations],
|
||||
);
|
||||
|
||||
useServicesStripInfo(services.length);
|
||||
|
||||
const { search } = useLocation();
|
||||
const tableColumns = useMemo(() => getColumns(search, true), [search]);
|
||||
const [RPS, setRPS] = useState(0);
|
||||
|
||||
@@ -18,7 +18,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
|
||||
import SkipOnBoardingModal from '../SkipOnBoardModal';
|
||||
import { useServicesStripInfo } from '../useServicesStripInfo';
|
||||
import ServiceTraceTable from './ServiceTracesTable';
|
||||
|
||||
function ServiceTraces(): JSX.Element {
|
||||
@@ -43,8 +42,6 @@ function ServiceTraces(): JSX.Element {
|
||||
|
||||
const services = data || [];
|
||||
|
||||
useServicesStripInfo(services.length);
|
||||
|
||||
const [skipOnboarding, setSkipOnboarding] = useState(
|
||||
localStorageGet(SKIP_ONBOARDING) === 'true',
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useServicesStripInfo } from '../useServicesStripInfo';
|
||||
|
||||
describe('useServicesStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('shows how many services are listed', () => {
|
||||
renderHook(() => useServicesStripInfo(18));
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '18 services' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says service, not services, when there is one', () => {
|
||||
renderHook(() => useServicesStripInfo(1));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 service',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero when there are none', () => {
|
||||
renderHook(() => useServicesStripInfo(0));
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 services',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() => useServicesStripInfo(18));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
export function useServicesStripInfo(count: number): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'service') }],
|
||||
[count],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
jest.mock('providers/App/App');
|
||||
jest.mock('hooks/useGetTenantLicense');
|
||||
|
||||
const mockAppContext = useAppContext as jest.MockedFunction<
|
||||
typeof useAppContext
|
||||
>;
|
||||
const mockLicense = useGetTenantLicense as jest.MockedFunction<
|
||||
typeof useGetTenantLicense
|
||||
>;
|
||||
|
||||
const flag = (name: FeatureKeys, active: boolean): Record<string, unknown> => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
function setup({
|
||||
chatSupport = true,
|
||||
premiumSupport = false,
|
||||
trialConverted = false,
|
||||
isCloudUser = true,
|
||||
isEnterpriseSelfHostedUser = false,
|
||||
isLoggedIn = true,
|
||||
pylonEnabled = true,
|
||||
isFetchingFeatureFlags = false,
|
||||
featureFlags = undefined as unknown,
|
||||
featureFlagsFetchError = null as unknown,
|
||||
activeLicense = {} as unknown,
|
||||
trialInfo = {} as unknown,
|
||||
} = {}): void {
|
||||
window.signozBootData = {
|
||||
settings: { pylon: { enabled: pylonEnabled } },
|
||||
} as never;
|
||||
|
||||
mockAppContext.mockReturnValue({
|
||||
featureFlags:
|
||||
featureFlags === undefined
|
||||
? [
|
||||
flag(FeatureKeys.CHAT_SUPPORT, chatSupport),
|
||||
flag(FeatureKeys.PREMIUM_SUPPORT, premiumSupport),
|
||||
]
|
||||
: featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
trialInfo: trialInfo && { trialConvertedToSubscription: trialConverted },
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} as never);
|
||||
|
||||
mockLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const state = (): ChatSupportState =>
|
||||
renderHook(() => useChatSupport()).result.current;
|
||||
|
||||
describe('useChatSupport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('pylon', () => {
|
||||
it('hands off to Pylon for a cloud user past trial', () => {
|
||||
setup({ trialConverted: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon for enterprise self-hosted', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon when premium support is on, card or not', () => {
|
||||
setup({ premiumSupport: true, trialConverted: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('offers nothing when Pylon is not configured server side', () => {
|
||||
setup({ trialConverted: true, pylonEnabled: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsCard', () => {
|
||||
it('offers the card flow to a cloud user still on trial', () => {
|
||||
setup({ trialConverted: false, premiumSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.NeedsCard);
|
||||
});
|
||||
|
||||
it('offers nothing to a non-cloud user needing a card', () => {
|
||||
setup({
|
||||
trialConverted: false,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unavailable', () => {
|
||||
it('offers nothing without the chat support flag', () => {
|
||||
setup({ chatSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing when logged out', () => {
|
||||
setup({ isLoggedIn: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing while the flags are still loading', () => {
|
||||
setup({ isFetchingFeatureFlags: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing before the licence has loaded', () => {
|
||||
setup({ activeLicense: null });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing on a tenant that is neither cloud nor enterprise', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing before trial info has loaded', () => {
|
||||
setup({ trialInfo: null });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('once the flags answer', () => {
|
||||
it('reads a fetch error as an answer, not as still loading', () => {
|
||||
setup({
|
||||
featureFlags: null,
|
||||
featureFlagsFetchError: new Error('flags unavailable'),
|
||||
});
|
||||
|
||||
// Nothing is granted without flags, so the chat support flag is absent.
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('settles once the flags finish loading', () => {
|
||||
setup({ isFetchingFeatureFlags: true });
|
||||
const { result, rerender } = renderHook(() => useChatSupport());
|
||||
|
||||
expect(result.current).toBe(ChatSupportState.Unavailable);
|
||||
|
||||
setup({ isFetchingFeatureFlags: false });
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(ChatSupportState.NeedsCard);
|
||||
});
|
||||
|
||||
it('offers the card flow even when Pylon is not configured', () => {
|
||||
setup({ trialConverted: false, pylonEnabled: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.NeedsCard);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export enum ChatSupportState {
|
||||
/** Pylon is configured for this user — hand off to the widget. */
|
||||
Pylon = 'pylon',
|
||||
/** Trial without a card — offer the Add Credit Card flow instead. */
|
||||
NeedsCard = 'needsCard',
|
||||
/** No support entry at all. */
|
||||
Unavailable = 'unavailable',
|
||||
}
|
||||
|
||||
export function useChatSupport(): ChatSupportState {
|
||||
const {
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
return useMemo(() => {
|
||||
const isReady =
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo;
|
||||
if (!isReady || !isLoggedIn) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const flag = (name: FeatureKeys): boolean =>
|
||||
featureFlags?.find((f) => f.name === name)?.active || false;
|
||||
|
||||
if (!flag(FeatureKeys.CHAT_SUPPORT)) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const needsCard =
|
||||
!flag(FeatureKeys.PREMIUM_SUPPORT) &&
|
||||
!trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (needsCard) {
|
||||
// The credit card flow is cloud-only.
|
||||
// TODO: offered even where Pylon is not configured, which is probably wrong.
|
||||
return isCloudUser
|
||||
? ChatSupportState.NeedsCard
|
||||
: ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const pylonConfigured = Boolean(
|
||||
window.signozBootData?.settings?.pylon?.enabled,
|
||||
);
|
||||
return (isCloudUser || isEnterpriseSelfHostedUser) && pylonConfigured
|
||||
? ChatSupportState.Pylon
|
||||
: ChatSupportState.Unavailable;
|
||||
}, [
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
featureFlagsFetchError,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingFeatureFlags,
|
||||
isLoggedIn,
|
||||
trialInfo,
|
||||
]);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantS
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
|
||||
import { useAIAssistantStripInfo } from './useAIAssistantStripInfo';
|
||||
|
||||
import styles from './AIAssistantPage.module.scss';
|
||||
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
|
||||
|
||||
@@ -43,8 +41,6 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useAIAssistantStripInfo();
|
||||
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind } from 'container/BottomStrip/types';
|
||||
|
||||
import { useAIAssistantStripInfo } from '../useAIAssistantStripInfo';
|
||||
|
||||
function seed(conversations: Record<string, unknown>): void {
|
||||
useAIAssistantStore.setState({ conversations } as never);
|
||||
}
|
||||
|
||||
describe('useAIAssistantStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('counts only the conversations that are not archived', () => {
|
||||
seed({
|
||||
a: { id: 'a' },
|
||||
b: { id: 'b' },
|
||||
c: { id: 'c', archived: true },
|
||||
});
|
||||
|
||||
renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.Text, text: '2 conversations' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says one conversation, not 1 conversations', () => {
|
||||
seed({ a: { id: 'a' } });
|
||||
|
||||
renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '1 conversation',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows zero when there are none', () => {
|
||||
seed({});
|
||||
|
||||
renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
|
||||
text: '0 conversations',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
seed({ a: { id: 'a' } });
|
||||
|
||||
const { unmount } = renderHook(() => useAIAssistantStripInfo());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -243,11 +243,3 @@ export const TooltipsInApprovalDiff: Story = {
|
||||
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation count in the bottom strip, in place of the build version.
|
||||
* Archived threads are left out of it.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
export function useAIAssistantStripInfo(): void {
|
||||
const conversations = useAIAssistantStore((state) => state.conversations);
|
||||
|
||||
const count = Object.values(conversations).filter(
|
||||
(conversation) => !conversation.archived,
|
||||
).length;
|
||||
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'conversation') }],
|
||||
[count],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -133,19 +133,3 @@ export const ColumnPicker: Story = {
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The rule count in the bottom strip, in place of the build version: the rows on
|
||||
* the page against the total, the same pair the table's own footer prints.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/** The same count on a second page, where the two numbers come apart. */
|
||||
export const BottomStripPaginated: Story = {
|
||||
args: { bottomStrip: true, rules: RULE_MAX },
|
||||
parameters: {
|
||||
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -125,11 +125,3 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The exception count in the bottom strip, in place of the build version: the
|
||||
* rows on the page against the total the count query returns.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
@@ -225,21 +225,17 @@ export const dashboardMocks = defineStoryMocks({
|
||||
}),
|
||||
),
|
||||
|
||||
// The variable bar resolves before the panels and stays laid out while
|
||||
// they load or fail, so its two endpoints answer on their own rather
|
||||
// than through the Data control.
|
||||
rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(values.variableValues) },
|
||||
}),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/variables/query',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(values.variableValues) },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(fieldValuesResponse(NAMESPACE_VALUES))),
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
|
||||
@@ -97,11 +97,3 @@ export const NavSettingsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The firing alert count in the bottom strip, in place of the build version. It
|
||||
* counts firing instances, so it does not match the alert rules widget above.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
@@ -295,11 +295,7 @@ const account = (
|
||||
provider,
|
||||
providerAccountId: ACCOUNTS[provider][index],
|
||||
config: accountConfig(provider),
|
||||
agentReport: {
|
||||
timestampMillis: Date.now() - 45 * 1000,
|
||||
data: null,
|
||||
syncState: null,
|
||||
},
|
||||
agentReport: { timestampMillis: Date.now() - 45 * 1000, data: null },
|
||||
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
removedAt: null,
|
||||
|
||||
@@ -61,13 +61,3 @@ export const Pagination: Story = {
|
||||
export const OverTrialLimit: Story = {
|
||||
args: { traffic: 'over-trial-limit', banner: 'trial-expiry' },
|
||||
};
|
||||
|
||||
/** The service count in the bottom strip, in place of the build version. */
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/** The same count on the span metrics table, which is the page's other path. */
|
||||
export const BottomStripSpanMetrics: Story = {
|
||||
args: { bottomStrip: true, mode: 'span-metrics' },
|
||||
};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind, StripTone } from 'container/BottomStrip/types';
|
||||
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
|
||||
describe('useTraceDetailsStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('puts the span and error counts in the strip', () => {
|
||||
renderHook(() =>
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: 600,
|
||||
totalErrorSpansCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.KeyValue, label: 'Spans', value: 600 },
|
||||
{
|
||||
kind: StripItemKind.KeyValue,
|
||||
label: 'Errors',
|
||||
value: 4,
|
||||
tone: StripTone.Error,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the error item untoned when nothing failed', () => {
|
||||
renderHook(() =>
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: 600,
|
||||
totalErrorSpansCount: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[1]).toMatchObject({
|
||||
value: 0,
|
||||
tone: StripTone.Default,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: 600,
|
||||
totalErrorSpansCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
@@ -145,10 +144,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
@@ -446,10 +441,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -65,24 +65,6 @@ export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The trace's span and error counts in the bottom strip, in place of the build
|
||||
* version, the error icon red while any span failed.
|
||||
*/
|
||||
export const BottomStrip: Story = {
|
||||
args: { bottomStrip: true },
|
||||
};
|
||||
|
||||
/** The strip over a trace with no failing span: the error icon stays neutral. */
|
||||
export const BottomStripNoErrors: Story = {
|
||||
args: { bottomStrip: true, errors: false },
|
||||
};
|
||||
|
||||
/** The strip mid-fetch, whose counts read zero until the trace resolves. */
|
||||
export const BottomStripLoading: Story = {
|
||||
args: { bottomStrip: true, dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** The waterfall renders once the trace resolves, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import {
|
||||
type StripItem,
|
||||
StripItemKind,
|
||||
StripTone,
|
||||
} from 'container/BottomStrip/types';
|
||||
|
||||
interface UseTraceDetailsStripInfoArgs {
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the counts rather than fetching them: the trace query key includes the
|
||||
* selected span, so a fetch here would fire on every span click.
|
||||
*/
|
||||
export function useTraceDetailsStripInfo({
|
||||
totalSpansCount,
|
||||
totalErrorSpansCount,
|
||||
}: UseTraceDetailsStripInfoArgs): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{
|
||||
kind: StripItemKind.KeyValue,
|
||||
label: 'Spans',
|
||||
value: totalSpansCount,
|
||||
prefix: <ChartNoAxesGantt size={13} />,
|
||||
},
|
||||
{
|
||||
kind: StripItemKind.KeyValue,
|
||||
label: 'Errors',
|
||||
value: totalErrorSpansCount,
|
||||
prefix: <TriangleAlert size={13} />,
|
||||
tone: totalErrorSpansCount > 0 ? StripTone.Error : StripTone.Default,
|
||||
},
|
||||
],
|
||||
[totalSpansCount, totalErrorSpansCount],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import type { GlobalMockArgs } from '../globals';
|
||||
|
||||
/** A page that puts nothing in the strip, so the shell is all there is. */
|
||||
function EmptyPage(): JSX.Element {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
/** The shell renders its entry points after the global config and the license settle. */
|
||||
const untilLoaded = { timeout: 10_000 };
|
||||
|
||||
/**
|
||||
* The strip along the foot of the shell, behind the `SAVED_VIEW_ENABLED` flag:
|
||||
* what the page puts on the left, the build version when it puts nothing, and
|
||||
* the shell's own Ask Noz and Support on the right. Support follows the chat
|
||||
* support the workspace has, the way the floating bubble it replaces did.
|
||||
*
|
||||
* Route: every page inside the app shell; these stories use `/home`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Foundations/Bottom Strip',
|
||||
tags: ['play'],
|
||||
component: EmptyPage,
|
||||
args: { bottomStrip: true },
|
||||
parameters: { signoz: { route: ROUTES.HOME, layout: 'app' } },
|
||||
} satisfies Meta<GlobalMockArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<GlobalMockArgs>;
|
||||
|
||||
/**
|
||||
* The build version on the left. With Noz off and no chat support configured,
|
||||
* the right side is empty.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Ask Noz and Support on the right, Support handing off to Pylon. */
|
||||
export const Actions: Story = {
|
||||
args: { noz: true, support: 'pylon' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A cloud trial without a card, whose Support offers the Add Credit Card modal
|
||||
* rather than Pylon. The floating bubble that used to offer it is gone.
|
||||
*/
|
||||
export const AddCard: Story = {
|
||||
args: { noz: true, support: 'add-card' },
|
||||
};
|
||||
|
||||
/** The Add Credit Card modal, opened from the strip's Support. */
|
||||
export const AddCardModal: Story = {
|
||||
args: { noz: true, support: 'add-card' },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('bottom-strip-support', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /Add Credit Card for Chat Support/i,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The pulsing dot beside Ask Noz while conversations wait on an approval. The
|
||||
* top nav's Noz entry carries the same count.
|
||||
*/
|
||||
export const NozAwaitingYou: Story = {
|
||||
args: { noz: true, nozAwaiting: 2, support: 'pylon' },
|
||||
};
|
||||
|
||||
/** Every tooltip the strip carries, held open. */
|
||||
export const Tooltips: Story = {
|
||||
args: { noz: true, support: 'pylon', tooltipsOpen: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The flag off, on a cloud trial without a card: no strip, and chat support is
|
||||
* the floating bubble in the corner.
|
||||
*/
|
||||
export const WithoutStrip: Story = {
|
||||
args: { bottomStrip: false, noz: true, support: 'add-card' },
|
||||
};
|
||||
|
||||
/** The Add Credit Card modal, opened from the floating bubble with the flag off. */
|
||||
export const WithoutStripAddCardModal: Story = {
|
||||
args: { bottomStrip: false, noz: true, support: 'add-card' },
|
||||
play: async (): Promise<void> => {
|
||||
// The bubble is an icon with no label or test id of its own.
|
||||
const bubble = await waitFor(() => {
|
||||
const found = document.querySelector<HTMLElement>(
|
||||
'.chat-support-gateway-btn',
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new Error('the floating support bubble never rendered');
|
||||
}
|
||||
|
||||
return found;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(bubble);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /Add Credit Card for Chat Support/i,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
|
||||
const raiseToast: NonNullable<Story['play']> = async () => {
|
||||
toast.success('Service account deleted');
|
||||
await screen.findByText('Service account deleted', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
/**
|
||||
* A toast with the strip on. Sonner pins itself to the foot of the viewport, so
|
||||
* without an offset it lands on top of Ask Noz and Support.
|
||||
*/
|
||||
export const WithToast: Story = {
|
||||
args: { noz: true, support: 'pylon' },
|
||||
play: raiseToast,
|
||||
};
|
||||
|
||||
/** The same toast with the flag off, back at sonner's own inset from the viewport. */
|
||||
export const WithoutStripToast: Story = {
|
||||
args: { bottomStrip: false, noz: true, support: 'pylon' },
|
||||
play: raiseToast,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { nozGlobalConfigHandler } from '@/storybook/msw/appShellHandlers';
|
||||
|
||||
import type { GlobalMockArgs } from '../globals';
|
||||
import TooltipsFixture from './TooltipsFixture';
|
||||
|
||||
@@ -48,8 +50,14 @@ export const ExportDownload: Story = {
|
||||
|
||||
/**
|
||||
* The label on the Noz entry point in the top nav. The AI assistant is off until
|
||||
* the backend ships a URL for it, so the story turns the Noz control on.
|
||||
* the backend ships a URL for it, so the story answers the global config with
|
||||
* one.
|
||||
*/
|
||||
export const NozEntryPoint: Story = {
|
||||
args: { site: 'noz', noz: true },
|
||||
args: { site: 'noz' },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [nozGlobalConfigHandler],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
@@ -18,7 +15,7 @@ import type { UserPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { setNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { choiceControl, toggleControl } from '../controls/controls';
|
||||
import { choiceControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import { RESPONSE_STATES, type ResponseState } from '../runtime/responseState';
|
||||
@@ -53,10 +50,6 @@ const SIDENAV_STATES = ['pinned', 'collapsed'] as const;
|
||||
|
||||
type SidenavState = (typeof SIDENAV_STATES)[number];
|
||||
|
||||
const SUPPORT_STATES = ['none', 'pylon', 'add-card'] as const;
|
||||
|
||||
type SupportState = (typeof SUPPORT_STATES)[number];
|
||||
|
||||
const {
|
||||
activeLicense: baseLicense,
|
||||
trialInfo: baseTrialInfo,
|
||||
@@ -209,25 +202,6 @@ const bannerContext = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The Add Credit Card offer is what a cloud user with chat support but without
|
||||
* premium support gets, so `add-card` only turns premium off. A deployment with
|
||||
* no chat support flag, such as `community`, stays without any entry.
|
||||
*/
|
||||
const supportFeatureFlags = (
|
||||
support: SupportState,
|
||||
featureFlags: FeatureFlagProps[] | null,
|
||||
): Partial<IAppContext> =>
|
||||
support === 'add-card'
|
||||
? {
|
||||
featureFlags: (featureFlags ?? []).map((flag) =>
|
||||
flag.name === FeatureKeys.PREMIUM_SUPPORT
|
||||
? { ...flag, active: false }
|
||||
: flag,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
|
||||
/**
|
||||
* `AppLayout` lays the shell out from the context rather than the API, so the
|
||||
* side nav only matches the real app when this is seeded.
|
||||
@@ -266,19 +240,6 @@ export const appShellMocks = defineStoryMocks({
|
||||
options: SIDENAV_STATES,
|
||||
value: 'pinned',
|
||||
}),
|
||||
bottomStrip: toggleControl('Bottom strip', {
|
||||
group: APP_SHELL,
|
||||
description:
|
||||
'The `SAVED_VIEW_ENABLED` localStorage flag the bottom strip ships behind.',
|
||||
value: false,
|
||||
}),
|
||||
support: choiceControl<SupportState>('Support', {
|
||||
group: APP_SHELL,
|
||||
description:
|
||||
'The chat support entry the shell offers. `none` is premium support with Pylon not configured; `pylon` configures it in the boot data; `add-card` turns premium support off, which offers a cloud trial the Add Credit Card modal instead. The Pylon widget itself never loads.',
|
||||
options: SUPPORT_STATES,
|
||||
value: 'none',
|
||||
}),
|
||||
dataState: choiceControl<ResponseState>('State', {
|
||||
group: DATA,
|
||||
description: 'How the endpoints the page owns answer.',
|
||||
@@ -287,32 +248,19 @@ export const appShellMocks = defineStoryMocks({
|
||||
}),
|
||||
},
|
||||
responseState: ({ dataState }) => dataState,
|
||||
config: ({ license, banner, sidenav, support }) => {
|
||||
config: ({ license, banner, sidenav }) => {
|
||||
const tenant = licenseContext(license);
|
||||
|
||||
return {
|
||||
appContext: {
|
||||
...tenant,
|
||||
...bannerContext(banner, tenant.activeLicense ?? null),
|
||||
...supportFeatureFlags(support, tenant.featureFlags ?? baseFeatureFlags),
|
||||
userPreferences: sidenavPreferences(sidenav === 'pinned'),
|
||||
},
|
||||
};
|
||||
},
|
||||
effect: ({ banner, bottomStrip, support }) => {
|
||||
effect: ({ banner }) => {
|
||||
setNoAuthMode(banner === 'no-auth');
|
||||
|
||||
if (bottomStrip) {
|
||||
set(LOCALSTORAGE.SAVED_VIEW_ENABLED, 'true');
|
||||
} else {
|
||||
remove(LOCALSTORAGE.SAVED_VIEW_ENABLED);
|
||||
}
|
||||
|
||||
const pylon = window.signozBootData?.settings?.pylon;
|
||||
|
||||
if (pylon) {
|
||||
pylon.enabled = support === 'pylon';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { composeStoryMocks } from '../controls/composeStoryMocks';
|
||||
import { appShellMocks } from './appShellMocks';
|
||||
import { authzMocks } from './authzMocks';
|
||||
import { nozMocks } from './nozMocks';
|
||||
import { tooltipMocks } from './tooltipMocks';
|
||||
|
||||
/**
|
||||
@@ -12,7 +11,6 @@ import { tooltipMocks } from './tooltipMocks';
|
||||
export const globalMocks = composeStoryMocks(
|
||||
authzMocks,
|
||||
appShellMocks,
|
||||
nozMocks,
|
||||
tooltipMocks,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { rest } from 'msw';
|
||||
import { AI_API_PATH } from 'api/AIAPIInstance';
|
||||
import type { ThreadListResponse } from 'api/ai-assistant/chat';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import type { ConversationStreamState } from 'container/AIAssistant/types';
|
||||
|
||||
import { countControl, toggleControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
import type { StoryMockArgs } from '../controls/types';
|
||||
import { nozGlobalConfigResponse } from '../msw/__story_mockdata__/appShell';
|
||||
import { nozGlobalConfigHandler } from '../msw/appShellHandlers';
|
||||
|
||||
const NOZ = 'Noz';
|
||||
|
||||
/** The streams this module seeds, so it only ever removes its own. */
|
||||
const AWAITING_PREFIX = 'storybook-awaiting-';
|
||||
|
||||
const noThreads: ThreadListResponse = { threads: [], hasMore: false };
|
||||
|
||||
const awaitingStream = (): ConversationStreamState => ({
|
||||
isStreaming: false,
|
||||
streamingContent: '',
|
||||
streamingStatus: 'awaiting_approval',
|
||||
streamingEvents: [],
|
||||
streamingMessageId: null,
|
||||
streamingActions: null,
|
||||
pendingApproval: null,
|
||||
pendingClarification: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* The AI assistant's entry points across the shell. A page that answers the
|
||||
* global config itself, such as the Noz page, wins over `noz`.
|
||||
*/
|
||||
export const nozMocks = defineStoryMocks({
|
||||
controls: {
|
||||
noz: toggleControl('Noz', {
|
||||
group: NOZ,
|
||||
description:
|
||||
'Answers the global config with an `ai_assistant_url`, which is what turns the assistant on.',
|
||||
value: false,
|
||||
}),
|
||||
nozAwaiting: countControl('Awaiting you', {
|
||||
group: NOZ,
|
||||
description:
|
||||
'Conversations blocked on an approval, which is what the pending badge on the Noz entry points counts.',
|
||||
value: 0,
|
||||
max: 3,
|
||||
}),
|
||||
},
|
||||
handlers: ({ noz }) =>
|
||||
noz
|
||||
? [
|
||||
nozGlobalConfigHandler,
|
||||
// The drawer loads the thread list on mount, while it is still closed.
|
||||
rest.get(
|
||||
`${nozGlobalConfigResponse.data.ai_assistant_url}${AI_API_PATH}/threads`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(noThreads)),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
effect: ({ nozAwaiting }) => {
|
||||
// The store is a singleton another story's mocks may have seeded, so
|
||||
// only this module's own streams are replaced.
|
||||
useAIAssistantStore.setState((state) => {
|
||||
Object.keys(state.streams)
|
||||
.filter((id) => id.startsWith(AWAITING_PREFIX))
|
||||
.forEach((id) => {
|
||||
delete state.streams[id];
|
||||
});
|
||||
|
||||
for (let index = 0; index < nozAwaiting; index += 1) {
|
||||
state.streams[`${AWAITING_PREFIX}${index}`] = awaitingStream();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export type NozArgs = StoryMockArgs<typeof nozMocks>;
|
||||
@@ -71,9 +71,10 @@ export const appShellHandlers = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Control-driven handlers are resolved ahead of `appShellHandlers`, so the Noz
|
||||
* control wires this in to override the global config's `ai_assistant_url`
|
||||
* without the default set doing so for every other story.
|
||||
* A story's own `parameters.msw.handlers` are resolved ahead of
|
||||
* `appShellHandlers`, so the `Foundations/Tooltips` Noz story wires this in to
|
||||
* override the global config's `ai_assistant_url` without the default set
|
||||
* doing so for every other story.
|
||||
*/
|
||||
export const nozGlobalConfigHandler = rest.get(
|
||||
'http://localhost/api/v1/global/config',
|
||||
|
||||
@@ -813,18 +813,6 @@ body.ai-assistant-panel-open {
|
||||
}
|
||||
}
|
||||
|
||||
body.bottom-strip-on {
|
||||
// Pylon re-shows its own launcher when the chat window opens, so
|
||||
// `hideChatBubble` cannot hold it down.
|
||||
.PylonChat-bubbleFrameContainer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.PylonChat-chatWindowFrameContainer {
|
||||
bottom: calc(var(--bottom-strip-height, 0px) + 12px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
[role='tab'] {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func (store *config) ListChannels(ctx context.Context, orgID string, params *ale
|
||||
}
|
||||
|
||||
if !params.Kind.IsZero() {
|
||||
q = q.Where("type = ?", params.Kind.StringValue())
|
||||
q = q.Where("type = ?", params.Kind.ToStoredType())
|
||||
}
|
||||
|
||||
q = q.
|
||||
|
||||
@@ -98,12 +98,11 @@ func (handler *handler) ListChannels(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
// This ensures that the UI receives an empty array instead of null
|
||||
v1Channels := make([]*alertmanagertypes.Channel, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
v1Channels = append(v1Channels, channel.ToV1Channel())
|
||||
if len(channels) == 0 {
|
||||
channels = make([]*alertmanagertypes.Channel, 0)
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, v1Channels)
|
||||
render.Success(rw, http.StatusOK, channels)
|
||||
}
|
||||
|
||||
func (handler *handler) ListAllChannels(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -153,7 +152,7 @@ func (handler *handler) GetChannelByID(rw http.ResponseWriter, req *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, channel.ToV1Channel())
|
||||
render.Success(rw, http.StatusOK, channel)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateChannelByID(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -273,7 +272,7 @@ func (handler *handler) CreateChannel(rw http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, channel.ToV1Channel())
|
||||
render.Success(rw, http.StatusCreated, channel)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -262,7 +262,7 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
}
|
||||
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
channel, receiver, err := postable.ToChannel(orgID)
|
||||
receiver, err := postable.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -280,6 +280,11 @@ func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := alertmanagertypes.NewChannelFromReceiverWithName(receiver, postable.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.configStore.CreateChannel(ctx, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
|
||||
return provider.configStore.Set(ctx, config)
|
||||
}))
|
||||
@@ -299,11 +304,15 @@ func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiver, err := channel.UpdateFromUpdatable(updatable)
|
||||
receiver, err := updatable.ToReceiver(channel.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := channel.Update(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
@@ -77,7 +74,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: authDomainPostableRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
@@ -149,7 +146,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: authDomainUpdatableRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
@@ -199,20 +196,16 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
|
||||
// The extracted names are the roles the request body's mapping grants at SSO
|
||||
// login — see authDomainEffectiveRoleNames.
|
||||
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
|
||||
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
|
||||
return authDomainEffectiveRoleNames(nil), nil
|
||||
}
|
||||
func authDomainPostableRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.BodyFields(func(req *authtypes.PostableAuthDomain) []string {
|
||||
return authDomainEffectiveRoleNames(req.RoleMapping)
|
||||
})
|
||||
}
|
||||
|
||||
roleMapping := new(authtypes.RoleMapping)
|
||||
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(roleMapping), nil
|
||||
}}
|
||||
func authDomainUpdatableRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.BodyFields(func(req *authtypes.UpdatableAuthDomain) []string {
|
||||
return authDomainEffectiveRoleNames(req.RoleMapping)
|
||||
})
|
||||
}
|
||||
|
||||
// The extracted names are the roles the stored domain's mapping grants at SSO
|
||||
|
||||
@@ -350,7 +350,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.BodyJSONPath("account_id"),
|
||||
ID: coretypes.BodyField(func(req *citypes.PostableAgentCheckIn) string { return req.ID }),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
@@ -377,7 +377,12 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.BodyJSONPath("cloudIntegrationId"),
|
||||
ID: coretypes.BodyField(func(req *citypes.PostableAgentCheckIn) string {
|
||||
if req.CloudIntegrationID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.CloudIntegrationID.StringValue()
|
||||
}),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -332,7 +332,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ParentResource: coretypes.ResourceMetaResourceIngestionKey,
|
||||
ParentID: coretypes.BodyJSONPath("keyId"),
|
||||
ParentID: coretypes.BodyField(func(req *gatewaytypes.PostableIngestionKeyLimit) string { return req.KeyID }),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceIngestionLimit,
|
||||
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -79,6 +80,14 @@ func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
|
||||
}}
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
|
||||
@@ -61,6 +61,7 @@ type provider struct {
|
||||
authzService authz.AuthZ
|
||||
orgHandler organization.Handler
|
||||
userHandler user.Handler
|
||||
userGetter user.Getter
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
authDomainModule authdomain.Module
|
||||
@@ -85,7 +86,6 @@ type provider struct {
|
||||
querierHandler querier.Handler
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
serviceAccountGetter serviceaccount.Getter
|
||||
userGetter user.Getter
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
@@ -106,6 +106,7 @@ func NewFactory(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
userGetter user.Getter,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
@@ -130,7 +131,6 @@ func NewFactory(
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
userGetter user.Getter,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
@@ -159,6 +159,7 @@ func NewFactory(
|
||||
authzService,
|
||||
orgHandler,
|
||||
userHandler,
|
||||
userGetter,
|
||||
sessionHandler,
|
||||
authDomainHandler,
|
||||
authDomainModule,
|
||||
@@ -183,7 +184,6 @@ func NewFactory(
|
||||
querierHandler,
|
||||
serviceAccountHandler,
|
||||
serviceAccountGetter,
|
||||
userGetter,
|
||||
factoryHandler,
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
@@ -214,6 +214,7 @@ func newProvider(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
userGetter user.Getter,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
@@ -238,7 +239,6 @@ func newProvider(
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
userGetter user.Getter,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
@@ -268,6 +268,7 @@ func newProvider(
|
||||
healthyC: make(chan struct{}),
|
||||
orgHandler: orgHandler,
|
||||
userHandler: userHandler,
|
||||
userGetter: userGetter,
|
||||
authzService: authzService,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
@@ -293,7 +294,6 @@ func newProvider(
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
serviceAccountGetter: serviceAccountGetter,
|
||||
userGetter: userGetter,
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
|
||||
@@ -461,10 +461,11 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
RequiresBody: true,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -483,10 +484,11 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
RequiresBody: true,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
pkghandler "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -55,6 +56,14 @@ func (handler *healthOpenAPIHandler) ResourceDefs() []pkghandler.ResourceDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) Request() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *healthOpenAPIHandler) BindBodyOptions() []binding.BindBodyOption {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
|
||||
provider.authzMiddleware.OpenAccess(provider.factoryHandler.Healthz),
|
||||
|
||||
@@ -358,10 +358,20 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.ServiceAccountID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.ServiceAccountID.StringValue()
|
||||
})),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyField(func(req *serviceaccounttypes.PostableServiceAccountRole) string {
|
||||
if req.RoleID.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return req.RoleID.StringValue()
|
||||
})),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
|
||||
@@ -86,11 +86,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceUser.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
@@ -107,8 +103,15 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
|
||||
TargetSelector: provider.roleSelector,
|
||||
TargetIDs: coretypes.BodyFields(func(req *authtypes.PostableUser) []string {
|
||||
roleIDs := make([]string, 0, len(req.UserRoles))
|
||||
for _, role := range req.UserRoles {
|
||||
roleIDs = append(roleIDs, role.ID.StringValue())
|
||||
}
|
||||
return roleIDs
|
||||
}),
|
||||
TargetSelector: provider.roleSelector,
|
||||
OptionalTargets: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
@@ -233,7 +236,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
@@ -254,18 +256,15 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceUser.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachParentChildResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
@@ -274,7 +273,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ParentID: coretypes.PathParam("id"),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
ChildIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
ChildIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
@@ -310,7 +309,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -423,10 +422,10 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyField(func(req *authtypes.PostableUserRole) string { return req.UserID.StringValue() })),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyField(func(req *authtypes.PostableUserRole) string { return req.RoleID.StringValue() })),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
@@ -499,7 +498,17 @@ func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtracto
|
||||
return "", nil
|
||||
}
|
||||
|
||||
userRole, err := provider.userRoleFromRequest(ec.Request)
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -514,7 +523,17 @@ func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtracto
|
||||
return "", nil
|
||||
}
|
||||
|
||||
userRole, err := provider.userRoleFromRequest(ec.Request)
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -522,17 +541,3 @@ func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtracto
|
||||
return userRole.RoleID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) userRoleFromRequest(req *http.Request) (*authtypes.UserRole, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.userGetter.GetUserRoleByOrgIDAndID(req.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.BodyJSONPath("name"),
|
||||
ID: coretypes.BodyField(func(req *zeustypes.PostableHost) string { return req.Name }),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,7 +11,6 @@ var (
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
|
||||
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
|
||||
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -76,15 +75,7 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
Name: FeatureResolveSemconvFamilies,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether trace, log, and metric queries resolve a semantic-convention name to all the spellings of its family",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseTraceAttributesJSON,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether trace queries read span attributes from the JSON columns",
|
||||
Description: "Controls whether trace queries resolve a semantic-convention name to all the spellings of its family",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user