Compare commits

...

11 Commits

Author SHA1 Message Date
Ashwin Bhatkal
06dd0ed3d3 feat(alert-channel-integrations): google chat final leg (#12335)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
* feat(alert-channels): add google chat channel type, defaults and url validation

Prefills the title and description templates verbatim from the backend's
DefaultGoogleChatReceiverConfig, and extracts the slack templates into
SlackInitialConfig so the form can restore them when the type changes.

* feat(alert-channels): add google chat option and form fields

* feat(alert-channels): wire google chat create, edit and test calls

* test(alert-channels): cover the google chat channel form

* fix(googlechat): use standard markdown in default templates for cardsV2
2026-08-05 06:13:25 +00:00
Ashwin Bhatkal
a6ac14344e fix(dashboard): count panel stats from the v2 spec (#12396)
* fix(dashboard): count panel stats from the v2 spec

The panel counters walked a top-level `widgets` array and returned early
when the key was missing. A v2 dashboard stores only `metadata` and
`spec`, with panels as a map under `spec.panels`, so every v2 row hit
that early return and all `dashboard.panels.*` stats stayed at zero.
`dashboard.count` was unaffected — it is a row count.

Read the v2 spec instead: count the panels under `spec.panels` and take
each panel's signal from its query envelope, reusing the typed read path
and `QueryEnvelope.GetSignal`. Signal-less queries (promql, clickhouse
sql, formulas) count towards the panel total only. v1 rows are no longer
parsed for panel stats and contribute to `dashboard.count` alone.

* test(dashboard): drop the constant name arg from the stats query helper

statsBuilderQuery only ever received "A", which go-lint flags via unparam.
A panel holds a single query, so the name never mattered to the assertions;
the composite test still names its sub-queries through statsBuilderQuerySpec.

* refactor(dashboard): move v2 panel stats to a perses_ file

All v2 code lives in perses_-prefixed files until the v1 code goes away.
Pure move of the stats block out of dashboard.go, tests alongside it.

* fix(dashboard): count create-v2 stats off the postable spec

CreateV2 already holds the postable dashboard, so decoding the storable
back into a v2 dashboard just to count its panels was a needless type
conversion on the create path.

Split the panel walk into addPanelStats over a DashboardSpec, and add
NewStatsFromPostableDashboardV2 for the create path. The storable variant
keeps its signature for the periodic collectors, which only have rows.
2026-08-05 04:16:02 +00:00
Vinicius Lourenço
e4e1c4b9e0 refactor(infrastructure-monitoring-v2): more ux/ui fixes after bug-bash (#12402)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(k8s-expanded-row): ensure values are correctly aligned

Ref: https://app.notion.com/p/signoz/Alignment-in-rows-inside-a-group-wrt-column-headers-is-not-correct-3b1fcc6bcd1980dd910bff84d1defe47?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(pods): ensure table aligment on group by

Ref: https://app.notion.com/p/signoz/Alignment-in-rows-inside-a-group-wrt-column-headers-is-not-correct-3b1fcc6bcd1980dd910bff84d1defe47?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): not allowing name columns to be sorted

Ref: https://app.notion.com/p/signoz/No-option-to-order-resources-by-name-3b1fcc6bcd198019a729d2575731387e?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): cannot sort name columns when group by is enabled

Ref: https://app.notion.com/p/signoz/Cannot-use-sort-when-group-by-is-added-3b2fcc6bcd19806d9514d27e4da26c35?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): sort key was not correct

Ref: https://app.notion.com/p/signoz/Review-all-sortable-columns-3b2fcc6bcd1980c4b267f3e35d4e875f?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* feat(infra-monitoring): add descriptions for each threshold of entity progress

Ref: https://app.notion.com/p/signoz/For-usage-metrics-the-info-icon-can-explain-the-colours-right-there-instead-of-just-saying-to-go-to-3b1fcc6bcd1980fc81a4f2b8179541b9?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): explorer button not allowing the back button

Ref: https://app.notion.com/p/signoz/After-click-to-go-to-metrics-explorer-on-chart-we-cannot-go-back-3b1fcc6bcd1980d7b52fd7dbe0b3e29f?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): polluted navigation history

Fixes the following issue:
- Go to namespaces
- Select “generator” and open the details
- Close the details
- Go to pods
- Group by namespaces

Expand “generator”, this will cause it to be redirected to namespaces category

Ref: https://app.notion.com/p/signoz/wrong-redirect-when-navigating-between-categories-3b2fcc6bcd1980e996fedd639ce72b01?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): get min max not detecting 1month as valid interval

Ref: https://app.notion.com/p/signoz/1month-is-not-supported-to-be-received-as-relativeTime-on-metrics-explorer-3b2fcc6bcd1980c0bd17f1e3d869e987?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* refactor(infra-monitoring): update table statistics for better auto page size

Ref: https://app.notion.com/p/signoz/change-the-baseline-values-for-calculated-page-size-3b2fcc6bcd19807caafbdedb725b6ab7?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link
2026-08-04 19:44:04 +00:00
Nityananda Gohain
816ae7760e feat: QB support for llm trace list and span list (#12027)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: support llm trace list and span list

* fix: take perf into consideration

* fix: more tests

* fix: more cleanup

* fix: cleanup and more tests

* fix: add resource fingerprint cte

* fix: edge cases and correct cost key

* fix: update integration test

* fix: update openapi

* fix: address comments

* fix: address comments

* fix: fix tests

* fix: add back the flag in metadata

* fix: remove source and change to builder ai query

* fix: updated openapi

* fix: minor cleanup

* fix: refactor as requested

* fix: address comments

* fix: address comments

* fix: remove tracefield. explicit rejection

* fix: remove comment

* fix: remove accidentally added file

* fix: add NewFactory

* fix: refactor integration tests

* fix: address comment

* fix: use assert

* fix: use assert and condense comments
2026-08-04 16:44:43 +00:00
Nageshbansal
985523df93 fix(version): detect docker under private cgroup namespaces (#12397)
* fix(version): detect docker under private cgroup namespaces

Docker defaults containers to a private cgroup namespace on cgroup v2
hosts (20.10+), which remaps /proc/self/cgroup to "0::/" and defeats
the runtime-name sniff, so compose installs report
deployment.mode=unknown. Check the container manager marker files
(/run/.containerenv, /.dockerenv) and the container env var first,
mirroring systemd's detect_container, and fall back to mountinfo when
the cgroup path is namespaced away.

* fix(version): drop the docker case from the container env check

docker never sets the container variable; /.dockerenv already covers it
2026-08-04 16:40:46 +00:00
Nageshbansal
5c9606bf9b feat(version): detect more cloud platforms (#12403)
Add metadata endpoint checks for openstack (covers OVHcloud), oracle,
alibaba, akamai (linode), ibm, and scaleway, and the FLY_APP_NAME
environment variable for fly.io. OpenStack must come before AWS since
it exposes an EC2-compatible endpoint.
2026-08-04 16:39:03 +00:00
Vikrant Gupta
f9461415bb chore(flagger): remove use_fine_grained_authz feature flag (#12400) 2026-08-04 16:27:28 +00:00
Tushar Vats
d93713c184 fix(querybuilder): keep DateTime64 columns native in aggregations (#12382)
A numeric coercion yields seconds since epoch, so max(timestamp) came back
as 1758113657.04 and the third-party APIs "Last Seen" rendered as January
1970. Both rules key on the physical column type, since no FieldDataType
denotes a timestamp.

- a time column is never coerced: it reaches the aggregate in its native
  type and the driver returns a time.Time
- a bare `timestamp` resolves to the intrinsic column alone; a same-named
  numeric attribute no longer joins the candidate union, where the mixed
  branches failed with "no supertype for DateTime64, Float64"
- the exists guard leaves the String bucket: `timestamp <> ''` becomes a
  typed epoch-zero comparison, verified equivalent on ClickHouse 25.5
  including a non-UTC server timezone
- max/min/quantile/count keep working; sum/avg and the rate_* variants now
  fail at the database instead of returning a rescaled number, pinned by
  integration tests until they are rejected up front
- tests/fixtures/traces.py wrote kind_string as the enum member name
  ("SPAN_KIND_CLIENT"), so any feature filtering on it matched nothing; it
  now maps the six kinds to the exporter's form ("Client")
- Last Seen assertions pin the encoding and the exact instant, rather than
  accepting either an RFC 3339 string or epoch millis
- drop the /overview/domain integration tests and the fixture surface that
  served only that route; the endpoint is no longer used
2026-08-04 15:07:48 +00:00
Vinicius Lourenço
0b5d3e939f chore(infrastructure-monitoring-v2): remove feature flag (#12398)
* Revert "chore: added infraMonitoringV2 feature flag (#12031)"

This reverts commit 1d441b63cf.

* chore(infrastructure-monitoring-v2): remove feature flag
2026-08-04 13:23:56 +00:00
Naman Verma
089f9eb4c6 chore: add api to retry migration for a dashboard (#12387)
* chore: add api to retry migration for a dashboard

* feat(dashboard): add retry migration action for legacy dashboards

The legacy-dashboard dialog only offered the dashboard ID and a link to
support. Now that the v1->v2 migration can be re-run on demand, let an
editor trigger it from there and fall back to support only if it still
fails.

Retrying needs edit access (the endpoint is EDITOR-gated), so viewers
keep the ID-and-support dialog unchanged.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-08-04 13:16:43 +00:00
Vinicius Lourenço
6c659e3b26 refactor(infrastructure-monitoring-v2): ensure pods docs redirect to right place & move events out of constants (#12359)
* fix(pods): redirecting to the wrong doc for chart

* refactor(events): move out of src/constants

* test(use-log): fix issue with mock

* docs(infra-monitoring): fix more broken doc path

* fix(pods): disable sort for pod restarts
2026-08-04 13:07:05 +00:00
136 changed files with 7008 additions and 831 deletions

View File

@@ -55,6 +55,7 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- promqlconformance
- querierauthz

View File

@@ -6902,6 +6902,7 @@ components:
Querybuildertypesv5QueryEnvelope:
discriminator:
mapping:
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
@@ -6910,6 +6911,7 @@ components:
propertyName: type
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
@@ -6924,6 +6926,15 @@ components:
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeBuilderAI:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
spec:
@@ -7037,6 +7048,7 @@ components:
Querybuildertypesv5QueryType:
enum:
- builder_query
- builder_ai_query
- builder_formula
- builder_trace_operator
- clickhouse_sql
@@ -15477,6 +15489,72 @@ paths:
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/migrate:
post:
deprecated: false
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
a dashboard already in the v2 schema is returned unchanged.'
operationId: MigrateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}

View File

@@ -80,15 +80,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
@@ -107,15 +98,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
infraMonitoringV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseInfraMonitoringV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
Active: infraMonitoringV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -24,6 +24,8 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",

View File

@@ -24,6 +24,8 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

@@ -52,6 +52,8 @@ import type {
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
MigrateDashboardV2200,
MigrateDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
@@ -1804,6 +1806,85 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
* @summary Migrate dashboard to v2
*/
export const migrateDashboardV2 = (
{ id }: MigrateDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<MigrateDashboardV2200>({
url: `/api/v2/dashboards/${id}/migrate`,
method: 'POST',
signal,
});
};
export const getMigrateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['migrateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof migrateDashboardV2>>,
{ pathParams: MigrateDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return migrateDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type MigrateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof migrateDashboardV2>>
>;
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Migrate dashboard to v2
*/
export const useMigrateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)

View File

@@ -4301,6 +4301,18 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
}
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
builder_ai_query = 'builder_ai_query',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
/**
* @type string
* @enum builder_ai_query
*/
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
/**
* @type boolean
@@ -4484,6 +4496,7 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
export type Querybuildertypesv5QueryEnvelopeDTO =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
@@ -8287,6 +8300,7 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_ai_query = 'builder_ai_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',
@@ -11164,6 +11178,17 @@ export type UnlockDashboardV2PathParameters = {
export type LockDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -1,9 +1,3 @@
import logEvent from 'api/common/logEvent';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getNavigationReferrer } from 'lib/navigation';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
export enum Events {
UPDATE_GRAPH_VISIBILITY_STATE = 'UPDATE_GRAPH_VISIBILITY_STATE',
UPDATE_GRAPH_MANAGER_TABLE = 'UPDATE_GRAPH_MANAGER_TABLE',
@@ -45,155 +39,3 @@ export enum InfraMonitoringEvents {
StatefulSet = 'statefulSet',
Volumes = 'volumes',
}
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle',
expression: string,
extraKeys?: string[],
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (extraKeys) {
extraKeys.forEach((key) => expressionKeys.push(key));
}
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_filter_customized', {
entity_type: entityType,
source,
expression_keys: [...new Set(expressionKeys)],
});
}
export function logInfraMonitoringListViewedEvent(
entity: InfraMonitoringEntity,
): void {
const referrer = getNavigationReferrer();
void logEvent('infra_list_viewed', {
entity,
referrer,
});
}
export function logInfraTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraColumnCustomizedEvent(
entityType: InfraMonitoringEntity,
columnsList: string[],
fontSize: string,
maxLinesPerRow: number,
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_customized', {
entity_type: entityType,
columns_list: columnsList,
font_size: fontSize,
max_lines_per_row: maxLinesPerRow,
source,
});
}
export function logInfraColumnSortedEvent(
entityType: InfraMonitoringEntity,
columnKey: string,
direction: 'asc' | 'desc',
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_sorted', {
entity_type: entityType,
column_key: columnKey,
direction,
source,
});
}
export function logInfraDrawerTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_drawer_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
expression: string,
filterSource: 'search' | 'logs',
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_drawer_filter_customized', {
entity_type: entityType,
tab,
expression_keys: [...new Set(expressionKeys)],
filter_source: filterSource,
});
}
export function logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
});
}
export function logInfraDrawerTabViewedEvent(
entityType: InfraMonitoringEntity,
tab: string,
isDefaultTab: boolean,
): void {
void logEvent('infra_drawer_tab_viewed', {
entity_type: entityType,
tab,
is_default_tab: isDefaultTab,
});
}
export function logInfraExplorerNavigatedEvent(params: {
entityType: InfraMonitoringEntity;
destination:
| 'metrics_explorer'
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
});
}

View File

@@ -9,8 +9,6 @@ export enum FeatureKeys {
ANOMALY_DETECTION = 'anomaly_detection',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -1,18 +1,28 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultVaule,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
@@ -225,7 +235,7 @@ describe('Create Alert Channel', () => {
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultVaule,
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
@@ -419,5 +429,150 @@ describe('Create Alert Channel', () => {
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
const validWebhookUrl =
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title contains the google chat template', () => {
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
);
});
it('Should check if Description contains the google chat template', () => {
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
});
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/webhook',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'google_chat_webhook_url_invalid',
}),
);
});
it('Should check if saving sends a googlechat_configs payload', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(screen.getByTestId('webhook-url-textbox'), validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'gchat-channel',
googlechat_configs: [
{
webhook_url: validWebhookUrl,
title: GoogleChatInitialConfig.title,
text: GoogleChatInitialConfig.text,
send_resolved: true,
},
],
});
});
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,
optionText: string,
): Promise<void> {
// the type dropdown opens on the inner search input of the antd select
await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByTitle(optionText));
}
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Slack} />);
await selectType(user, 'Google Chat');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
await selectType(user, 'Slack');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
slackTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
slackDescriptionDefaultValue,
);
});
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
await selectType(user, 'Pagerduty');
await waitFor(() =>
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
),
);
});
});
});
});

View File

@@ -5,7 +5,7 @@ import {
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultVaule,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
@@ -150,7 +150,7 @@ describe('Create Alert Channel (Normal User)', () => {
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultVaule,
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {

View File

@@ -104,6 +104,7 @@ export enum ChannelType {
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
@@ -125,3 +126,11 @@ export interface MsTeamsChannel extends Channel {
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}

View File

@@ -1,4 +1,51 @@
import { EmailChannel, OpsgenieChannel, PagerChannel } from './config';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from './config';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<SlackChannel> = {
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
};
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
// which the backend applies when title / text are left empty
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
};
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
@@ -446,3 +493,26 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
</body>
</html>`,
};
// prefilled values of every channel type, keyed by type so the form can apply
// exactly one set of defaults and swap it when the type changes
export const ChannelInitialConfig: Record<
ChannelType,
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,
[ChannelType.Webhook]: {},
};

View File

@@ -14,16 +14,24 @@ import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useCreateChannel,
useTestChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -31,12 +39,12 @@ import {
ValidatePagerChannel,
WebhookChannel,
} from './config';
import { ChannelInitialConfig } from './defaults';
import {
EmailInitialConfig,
OpsgenieInitialConfig,
PagerInitialConfig,
} from './defaults';
import { isChannelType } from './utils';
isChannelType,
isValidGoogleChatWebhookURL,
prepareGoogleChatRequest,
} from './utils';
import './CreateAlertChannels.styles.scss';
@@ -60,69 +68,38 @@ function CreateAlertChannels({
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel
EmailChannel &
GoogleChatChannel
>
>({
>(() => ({
send_resolved: true,
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
});
...ChannelInitialConfig[preType],
}));
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: createChannel } = useCreateChannel();
const { mutateAsync: testChannel } = useTestChannel();
const [type, setType] = useState<ChannelType>(preType);
const onTypeChangeHandler = useCallback(
(value: string) => {
const currentType = type;
setType(value as ChannelType);
if (value === ChannelType.Pagerduty && currentType !== value) {
// reset config to pager defaults
setSelectedConfig({
name: selectedConfig?.name,
send_resolved: selectedConfig.send_resolved,
...PagerInitialConfig,
});
const nextType = value as ChannelType;
if (nextType === type) {
return;
}
if (value === ChannelType.Opsgenie && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...OpsgenieInitialConfig,
}));
}
setType(nextType);
// reset config to email defaults
if (value === ChannelType.Email && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...EmailInitialConfig,
}));
}
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
formInstance.setFieldsValue(defaults);
},
[type, selectedConfig],
[type, formInstance],
);
const prepareSlackRequest = useCallback(
@@ -407,6 +384,56 @@ function CreateAlertChannels({
showErrorModal,
]);
const validateGoogleChatConfig = useCallback((): boolean => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return false;
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
notifications.error({
message: 'Error',
description: t('google_chat_webhook_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.webhook_url, notifications, t]);
const onGoogleChatHandler = useCallback(async () => {
if (!validateGoogleChatConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
@@ -424,6 +451,7 @@ function CreateAlertChannels({
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
};
if (isChannelType(value)) {
@@ -455,6 +483,7 @@ function CreateAlertChannels({
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
notifications,
t,
],
@@ -492,6 +521,13 @@ function CreateAlertChannels({
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
if (!validateGoogleChatConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
@@ -513,7 +549,11 @@ function CreateAlertChannels({
status: 'Test success',
});
} catch (error) {
showErrorModal(error as APIError);
showErrorModal(
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
);
logEvent('Alert Channel: Test notification', {
type: channelType,
@@ -535,6 +575,8 @@ function CreateAlertChannels({
prepareSlackRequest,
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
testChannel,
notifications,
],
);
@@ -562,9 +604,6 @@ function CreateAlertChannels({
initialValue: {
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
...EmailInitialConfig,
},
}}
/>

View File

@@ -1,4 +1,39 @@
import { ChannelType } from './config';
import {
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelType, GoogleChatChannel } from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
);
} catch {
return false;
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});

View File

@@ -14,10 +14,17 @@ import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useTestChannel,
useUpdateChannelByID,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -25,10 +32,15 @@ import {
ValidatePagerChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
prepareGoogleChatRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
function EditAlertChannels({
initialValue,
@@ -45,7 +57,8 @@ function EditAlertChannels({
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel
EmailChannel &
GoogleChatChannel
>
>({
...initialValue,
@@ -54,6 +67,26 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: updateChannel } = useUpdateChannelByID();
const { mutateAsync: testChannel } = useTestChannel();
const notifyError = useCallback(
(error: unknown): APIError => {
const apiError =
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
notifications.error({
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
return apiError;
},
[notifications],
);
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -364,6 +397,61 @@ function EditAlertChannels({
}
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
const validateGoogleChatConfig = useCallback((): string => {
if (!selectedConfig?.webhook_url) {
return t('webhook_url_required');
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
return t('google_chat_webhook_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onGoogleChatEditHandler = useCallback(async () => {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareGoogleChatRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
@@ -379,6 +467,8 @@ function EditAlertChannels({
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
@@ -397,6 +487,7 @@ function EditAlertChannels({
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
],
);
@@ -438,6 +529,19 @@ function EditAlertChannels({
await testEmail(request);
}
break;
case ChannelType.GoogleChat: {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
@@ -459,10 +563,7 @@ function EditAlertChannels({
status: 'Test success',
});
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
notifyError(error);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
@@ -476,6 +577,9 @@ function EditAlertChannels({
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
notifyError,
validateGoogleChatConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,
prepareSlackRequest,

View File

@@ -0,0 +1,82 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidGoogleChatWebhookURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('google_chat_webhook_url_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_google_chat_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
/>
</Form.Item>
<Form.Item name="title" label={t('field_slack_title')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</>
);
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
}
export default GoogleChat;

View File

@@ -9,6 +9,7 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
@@ -17,6 +18,7 @@ import {
import history from 'lib/history';
import EmailSettings from './Settings/Email';
import GoogleChatSettings from './Settings/GoogleChat';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
@@ -49,6 +51,8 @@ function FormAlertChannels({
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
@@ -129,6 +133,14 @@ function FormAlertChannels({
<Select.Option value="msteams" key="msteams" data-testid="select-option">
Microsoft Teams
</Select.Option>
<Select.Option
value="googlechat"
key="googlechat"
data-testid="select-option"
>
Google Chat
</Select.Option>
</Select>
</Form.Item>
@@ -176,7 +188,8 @@ interface FormAlertChannelsProps {
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel
EmailChannel &
GoogleChatChannel
>
>
>;

View File

@@ -17,11 +17,7 @@ import {
QuickFilterChangeEventData,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
@@ -57,6 +53,10 @@ import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);

View File

@@ -1,5 +1,4 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { logInfraFilterCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
StatusFilterValue,
@@ -9,6 +8,7 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import styles from './StatusFilter.module.scss';
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
const statusOptions: Array<{
label: string;

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import {
@@ -10,6 +9,7 @@ import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
import { getStrokeColorForPercent } from 'container/InfraMonitoringK8sV2/components/EntityProgressBar.utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import {
getHostQueryPayload,
@@ -18,26 +18,6 @@ import {
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
export function getProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_SAKURA_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export function getMemoryProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_CHERRY_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export type HostDetailMetadataConfigType =
K8sDetailsMetadataConfig<InframonitoringtypesHostRecordDTO>;
export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
@@ -79,7 +59,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getProgressColor(Number(value))}
strokeColor={getStrokeColorForPercent('cpu', Number(value))}
showInfo
/>
),
@@ -90,7 +70,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getMemoryProgressColor(Number(value))}
strokeColor={getStrokeColorForPercent('memory', Number(value))}
showInfo
/>
),

View File

@@ -9,6 +9,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
import {
EntityProgressBar,
EntityProgressThresholds,
ExpandButtonWrapper,
GroupedStatusCounts,
ValidateColumnValueWrapper,
@@ -98,7 +99,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
),
},
{
id: 'hostName',
id: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Hostname"
@@ -108,7 +109,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -168,7 +169,10 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
{
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#cpu-usage">
<ColumnHeader
docPath="/infrastructure-monitoring/host-monitoring#cpu-usage"
tooltip={<EntityProgressThresholds type="cpu" />}
>
CPU Usage
</ColumnHeader>
),
@@ -195,7 +199,9 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader
tooltip="Excluding cache memory."
tooltip={
<EntityProgressThresholds type="memory" note="Excluding cache memory." />
}
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
>
Memory Usage (WSS)
@@ -221,9 +227,12 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
},
},
{
id: 'diskUsage',
id: 'disk_usage',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
<ColumnHeader
docPath="/infrastructure-monitoring/host-monitoring#disk-usage"
tooltip={<EntityProgressThresholds type="disk" />}
>
Disk Usage
</ColumnHeader>
),

View File

@@ -3,13 +3,14 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ColumnHeader.module.scss';
import cx from 'classnames';
import { MouseEventHandler } from 'react';
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
docPath?: string;
tooltip?: string;
tooltip?: React.ReactNode;
className?: string;
}
@@ -19,6 +20,9 @@ function ColumnHeader({
tooltip,
className,
}: ColumnHeaderProps): JSX.Element {
const stopPropagationHandler: MouseEventHandler = (e): void =>
e.stopPropagation();
const renderContent = (): React.ReactNode => {
if (children) {
return children;
@@ -30,21 +34,25 @@ function ColumnHeader({
const renderInfoIcon = (): React.ReactNode => {
if (docPath) {
const tooltipTitle = tooltip || 'Not sure what this means?';
const isJustStringTitle = typeof tooltipTitle === 'string';
return (
<TooltipSimple
arrow
title={
<>
<div onClick={stopPropagationHandler}>
{tooltipTitle}{' '}
<a
href={`${DOCS_BASE_URL}${docPath}`}
target="_blank"
rel="noopener"
onClick={(e): void => e.stopPropagation()}
onClick={stopPropagationHandler}
>
Learn more.
{isJustStringTitle
? 'Learn more.'
: 'Check the documentation to learn more.'}
</a>
</>
</div>
}
>
<div className={styles.infoIcon}>
@@ -56,7 +64,9 @@ function ColumnHeader({
if (tooltip) {
return (
<TooltipSimple title={tooltip}>
<TooltipSimple
title={<div onClick={stopPropagationHandler}>{tooltip}</div>}
>
<div className={styles.infoIcon}>
<Info size="md" />
</div>

View File

@@ -12,11 +12,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import {
InfraMonitoringEvents,
logInfraDrawerTabViewedEvent,
logInfraExplorerNavigatedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
@@ -50,6 +46,8 @@ import { K8sBaseDetailsContentProps } from './types';
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
import { logInfraDrawerTabViewedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetailsContent<T>({

View File

@@ -9,11 +9,7 @@ import TanStackTable, {
useHiddenColumnIds,
useTableParams,
} from 'components/TanStackTableView';
import {
InfraMonitoringEvents,
logInfraColumnSortedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
@@ -48,6 +44,10 @@ import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentation
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
import {
logInfraColumnSortedEvent,
logInfraTimeRangeCustomizedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
@@ -128,6 +128,8 @@ export function K8sBaseList<
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
headerHeight: 58,
paginationHeight: 52,
});
const {
@@ -436,16 +438,17 @@ export function K8sBaseList<
isFetching={isFetching}
cancelQuery={cancelQuery}
/>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
<div ref={containerRef} className={styles.tableContainer}>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
{isError && (
<Typography>
{data?.error?.toString() || 'Something went wrong'}

View File

@@ -13,7 +13,7 @@
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-left-override: 26px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {

View File

@@ -10,19 +10,19 @@ import TanStackTable, {
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { QueryParams } from 'constants/query';
import { CornerDownRight } from '@signozhq/icons';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { v4 as uuid } from 'uuid';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { logInfraColumnSortedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
@@ -36,6 +36,9 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;
@@ -92,7 +95,6 @@ export function K8sExpandedRow<
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const location = useLocation();
const queryClient = useQueryClient();
@@ -258,13 +260,26 @@ export function K8sExpandedRow<
},
};
const newUrlQuery = new URLSearchParams(urlQuery.toString());
newUrlQuery.set(
const searchParams = getUnstableCurrentSearchParams();
searchParams.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED);
searchParams.delete(orderByParamKey);
searchParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, '1');
if (orderBy) {
searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
JSON.stringify(orderBy),
);
}
safeNavigate(`${location.pathname}?${searchParams.toString()}`);
};
const total = data?.total ?? 0;
@@ -276,6 +291,7 @@ export function K8sExpandedRow<
color="secondary"
variant="outlined"
className={styles.viewAllButton}
data-testid="expanded-row-view-all"
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>

View File

@@ -2,10 +2,7 @@ import React, { useCallback, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
InfraMonitoringEvents,
logInfraFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
@@ -24,6 +21,7 @@ import {
import { useInfraMonitoringPageListing } from '../hooks';
import styles from './K8sHeader.module.scss';
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;

View File

@@ -4,19 +4,34 @@ import { Select } from 'antd';
import { Download, SlidersVertical } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import {
InfraMonitoringEvents,
logInfraGroupByCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
} from '../hooks';
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
import styles from './K8sTableToolbar.module.scss';
import { logInfraGroupByCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
const NAME_COLUMN_KEYS: Set<string> = new Set([
INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
]);
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
@@ -37,11 +52,17 @@ function K8sTableToolbar({
useInfraMonitoringGroupByData(entity);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
if (orderBy && NAME_COLUMN_KEYS.has(orderBy.columnName)) {
void setOrderBy(null);
}
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
@@ -52,15 +73,16 @@ function K8sTableToolbar({
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, eventCategory, setCurrentPage, setGroupBy],
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
);
return (
<div className={styles.toolbar}>
<div className={styles.groupByContainer}>
<div className={styles.groupByContainer} data-testid="k8s-table-group-by">
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
data-testid="k8s-table-group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}

View File

@@ -1370,4 +1370,127 @@ describe('K8sBaseList', () => {
).resolves.toBeInTheDocument();
});
});
describe('groupBy change clears orderBy', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [{ id: 'item-1' }],
total: 1,
error: null,
});
server.use(
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
res(ctx.json({ status: 'success', data: { ready: true } })),
),
rest.get('http://localhost/api/v1/fields/keys', (_, res, ctx) =>
res(
ctx.json({
status: 'success',
data: {
keys: {
resource: [{ name: 'k8s.namespace.name' }],
},
},
}),
),
),
);
});
it('should clear orderBy for name columns when groupBy is changed', async () => {
const user = userEvent.setup();
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// k8s.pod.name is a name column - should be cleared
orderBy: JSON.stringify({ columnName: 'k8s.pod.name', order: 'desc' }),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
});
// Open group by dropdown using testId
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
const groupBySelect = groupByContainer.querySelector(
'.ant-select-selector',
) as Element;
await user.click(groupBySelect);
// Wait for options to load and click on the namespace option
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
await user.click(namespaceOption);
// Verify orderBy was cleared (set to null) for name column
await waitFor(() => {
const orderByCalls = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter((v) => v !== undefined);
const hasOrderByCleared = orderByCalls.some((v) => v === null);
expect(hasOrderByCleared).toBe(true);
});
});
it('should keep orderBy for non-name columns when groupBy is changed', async () => {
const user = userEvent.setup();
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// cpu is NOT a name column - should be kept
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
});
// Open group by dropdown using testId
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
const groupBySelect = groupByContainer.querySelector(
'.ant-select-selector',
) as Element;
await user.click(groupBySelect);
// Wait for options to load and click on the namespace option
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
await user.click(namespaceOption);
// Verify orderBy was NOT cleared for non-name column
await waitFor(() => {
const orderByCalls = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter((v) => v !== undefined);
// orderBy should never be set to null
const hasOrderByCleared = orderByCalls.some((v) => v === null);
expect(hasOrderByCleared).toBe(false);
});
});
});
});

View File

@@ -1,13 +1,13 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import { TableColumnDef, useColumnStore } from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../../constants';
import { useInfraMonitoringTablePreferencesStore } from '../useInfraMonitoringTablePreferencesStore';
import { useLogEventForColumnCustomized } from '../useLogEventForColumnCustomized';
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
jest.mock('constants/events', () => ({
jest.mock('container/InfraMonitoringK8sV2/Base/events', () => ({
logInfraColumnCustomizedEvent: jest.fn(),
}));

View File

@@ -3,7 +3,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Compass } from '@signozhq/icons';
import { TextNoData } from '../../../components/TextNoData';
import { logInfraExplorerNavigatedEvent } from 'constants/events';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -17,6 +16,7 @@ import {
} from '../../../constants';
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
import styles from './EntityCountsSection.module.scss';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
export interface EntityCountConfig<T> {
label: string;

View File

@@ -0,0 +1,113 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { getNavigationReferrer } from 'lib/navigation';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle',
expression: string,
extraKeys?: string[],
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (extraKeys) {
extraKeys.forEach((key) => expressionKeys.push(key));
}
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_filter_customized', {
entity_type: entityType,
source,
expression_keys: [...new Set(expressionKeys)],
});
}
export function logInfraMonitoringListViewedEvent(
entity: InfraMonitoringEntity,
): void {
const referrer = getNavigationReferrer();
void logEvent('infra_list_viewed', {
entity,
referrer,
});
}
export function logInfraTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraColumnCustomizedEvent(
entityType: InfraMonitoringEntity,
columnsList: string[],
fontSize: string,
maxLinesPerRow: number,
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_customized', {
entity_type: entityType,
columns_list: columnsList,
font_size: fontSize,
max_lines_per_row: maxLinesPerRow,
source,
});
}
export function logInfraColumnSortedEvent(
entityType: InfraMonitoringEntity,
columnKey: string,
direction: 'asc' | 'desc',
source: 'list' | 'expanded',
): void {
void logEvent('infra_column_sorted', {
entity_type: entityType,
column_key: columnKey,
direction,
source,
});
}
export function logInfraGroupByCustomizedEvent(
entityType: InfraMonitoringEntity,
groupByKeysList: string[],
): void {
void logEvent('infra_group_by_customized', {
entity_type: entityType,
group_by_keys_list: groupByKeysList,
});
}
export function logInfraExplorerNavigatedEvent(params: {
entityType: InfraMonitoringEntity;
destination:
| 'metrics_explorer'
| 'logs_explorer'
| 'traces_explorer'
| 'k8s_list';
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
tab: string;
sourceKey: string | null;
drawerDurationMsAtNavigation: number | null;
}): void {
void logEvent('infra_explorer_navigated', {
entity_type: params.entityType,
destination: params.destination,
source: params.source,
tab: params.tab,
source_key: params.sourceKey,
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
});
}

View File

@@ -4,7 +4,6 @@ import {
useColumnOrder,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { logInfraColumnCustomizedEvent } from 'constants/events';
import { InfraMonitoringEntity } from '../constants';
@@ -13,6 +12,7 @@ import {
useInfraMonitoringLineClamp,
} from './useInfraMonitoringTablePreferencesStore';
import { sortByColumnOrder } from './utils';
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
interface UseEmitColumnCustomizedParams<TData> {
entity: InfraMonitoringEntity;

View File

@@ -60,7 +60,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
},
},
{
id: 'clusterName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
@@ -70,7 +70,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
),
accessorFn: (row): string => row.clusterName || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -69,7 +70,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
},
},
{
id: 'daemonsetName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
@@ -80,7 +81,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -174,7 +175,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
@@ -192,7 +196,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
<EntityProgressBar value={cpuRequest} type="cpu-request" />
</ValidateColumnValueWrapper>
);
},
@@ -200,7 +204,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -217,7 +224,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
</ValidateColumnValueWrapper>
);
},
@@ -251,7 +258,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
@@ -269,7 +279,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
<EntityProgressBar value={memoryRequest} type="memory-request" />
</ValidateColumnValueWrapper>
);
},
@@ -277,7 +287,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -294,7 +307,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
<EntityProgressBar value={memoryLimit} type="memory-limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -70,7 +71,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
},
},
{
id: 'deploymentName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
@@ -81,7 +82,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -162,7 +163,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
@@ -180,7 +184,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
<EntityProgressBar value={cpuRequest} type="cpu-request" />
</ValidateColumnValueWrapper>
);
},
@@ -188,7 +192,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -205,7 +212,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
</ValidateColumnValueWrapper>
);
},
@@ -238,7 +245,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
@@ -256,7 +266,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
<EntityProgressBar value={memoryRequest} type="memory-request" />
</ValidateColumnValueWrapper>
);
},
@@ -264,7 +274,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -281,7 +294,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
<EntityProgressBar value={memoryLimit} type="memory-limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -3,10 +3,7 @@ import { Undo } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import {
InfraMonitoringEvents,
logInfraDrawerTimeRangeCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -17,6 +14,7 @@ import {
import { useEntityDetailsTime } from './useEntityDetailsTime';
import styles from './EntityDateTimeSelector.module.scss';
import { logInfraDrawerTimeRangeCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface EntityDateTimeSelectorProps {
eventEntity: string;

View File

@@ -16,10 +16,7 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
@@ -41,6 +38,7 @@ import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface EventDataType {
key: string;

View File

@@ -20,10 +20,7 @@ import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
@@ -53,6 +50,7 @@ import { isModifierKeyPressed } from 'utils/app';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface Props {
eventEntity: string;

View File

@@ -2,10 +2,7 @@ import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import {
InfraMonitoringEvents,
logInfraExplorerNavigatedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
@@ -35,6 +32,7 @@ import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
interface EntityMetricsProps<T> {
entity: T;

View File

@@ -16,10 +16,7 @@ import {
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import {
InfraMonitoringEvents,
logInfraDrawerFilterCustomizedEvent,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -41,6 +38,7 @@ import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
interface Props {
eventEntity: string;

View File

@@ -0,0 +1,48 @@
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import logEvent from 'api/common/logEvent';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isCustomTimeRange } from 'store/globalTime';
export function logInfraDrawerTimeRangeCustomizedEvent(
entityType: InfraMonitoringEntity,
rangeLabel: string,
): void {
void logEvent('infra_drawer_time_range_customized', {
entity_type: entityType,
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
});
}
export function logInfraDrawerFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
expression: string,
filterSource: 'search' | 'logs',
): void {
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
(pair) => pair.key,
);
if (expressionKeys.length === 0) {
return;
}
void logEvent('infra_drawer_filter_customized', {
entity_type: entityType,
tab,
expression_keys: [...new Set(expressionKeys)],
filter_source: filterSource,
});
}
export function logInfraDrawerTabViewedEvent(
entityType: InfraMonitoringEntity,
tab: string,
isDefaultTab: boolean,
): void {
void logEvent('infra_drawer_tab_viewed', {
entity_type: entityType,
tab,
is_default_tab: isDefaultTab,
});
}

View File

@@ -51,14 +51,14 @@ import {
} from './hooks';
import styles from './InfraMonitoringK8s.module.scss';
import {
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
InfraMonitoringEvents,
} from 'constants/events';
import { InfraMonitoringEvents } from 'constants/events';
import logEvent from 'api/common/logEvent';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
logInfraFilterCustomizedEvent,
logInfraMonitoringListViewedEvent,
} from 'container/InfraMonitoringK8sV2/Base/events';
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);

View File

@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -63,7 +64,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
},
},
{
id: 'jobName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
@@ -74,7 +75,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -158,7 +159,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
@@ -176,7 +180,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="CPU Request"
rowId={rowId}
>
<EntityProgressBar value={cpuRequest} type="request" />
<EntityProgressBar value={cpuRequest} type="cpu-request" />
</ValidateColumnValueWrapper>
);
},
@@ -184,7 +188,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -201,7 +208,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="CPU Limit"
rowId={rowId}
>
<EntityProgressBar value={cpuLimit} type="limit" />
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
</ValidateColumnValueWrapper>
);
},
@@ -234,7 +241,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
@@ -252,7 +262,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="Memory Request"
rowId={rowId}
>
<EntityProgressBar value={memoryRequest} type="request" />
<EntityProgressBar value={memoryRequest} type="memory-request" />
</ValidateColumnValueWrapper>
);
},
@@ -260,7 +270,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -277,7 +290,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="Memory Limit"
rowId={rowId}
>
<EntityProgressBar value={memoryLimit} type="limit" />
<EntityProgressBar value={memoryLimit} type="memory-limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -111,7 +111,8 @@ export const namespaceWidgetInfo = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores',
docPath:
'/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores-1',
},
{
title: 'Memory Usage (bytes)',

View File

@@ -66,7 +66,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
},
},
{
id: 'namespaceName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Namespace Name"
@@ -76,7 +76,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
),
accessorFn: (row): string => row.namespaceName || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -57,7 +57,7 @@ export const nodeWidgetInfo = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores',
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores-1',
},
{
title: 'Memory Usage (bytes)',

View File

@@ -68,7 +68,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
},
},
{
id: 'nodeName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Node Name"
@@ -78,7 +78,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
),
accessorFn: (row): string => row.nodeName || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -67,7 +67,7 @@ export const podWidgetInfo = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores',
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
},
{
title: 'CPU Request, Limit Utilization',

View File

@@ -17,6 +17,7 @@ import {
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -68,7 +69,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
},
},
{
id: 'podName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Pod Name"
@@ -79,7 +80,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -96,7 +97,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): string => row.podStatus,
width: { min: 160 },
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
@@ -175,7 +176,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
),
accessorFn: (row): number => row.podRestarts,
width: { min: 140 },
enableSort: true,
enableSort: false,
cell: ({ value, rowId }): React.ReactNode => {
const restarts = value as number;
return (
@@ -193,7 +194,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
@@ -210,7 +214,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
<EntityProgressBar value={cpuRequest} type="cpu-request" />
</ValidateColumnValueWrapper>
);
},
@@ -218,7 +222,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -234,7 +241,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
</ValidateColumnValueWrapper>
);
},
@@ -266,7 +273,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
@@ -283,7 +293,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
<EntityProgressBar value={memoryRequest} type="memory-request" />
</ValidateColumnValueWrapper>
);
},
@@ -291,7 +301,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -307,7 +320,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
<EntityProgressBar value={memoryLimit} type="memory-limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -70,7 +71,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
},
},
{
id: 'statefulsetName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="StatefulSet Name"
@@ -81,7 +82,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -165,7 +166,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
@@ -183,7 +187,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
<EntityProgressBar value={cpuRequest} type="cpu-request" />
</ValidateColumnValueWrapper>
);
},
@@ -191,7 +195,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -208,7 +215,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
</ValidateColumnValueWrapper>
);
},
@@ -242,7 +249,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
@@ -260,7 +270,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
<EntityProgressBar value={memoryRequest} type="memory-request" />
</ValidateColumnValueWrapper>
);
},
@@ -268,7 +278,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-">
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -285,7 +298,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
<EntityProgressBar value={memoryLimit} type="memory-limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -64,7 +64,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'pvcName',
id: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="PVC Name"
@@ -74,7 +74,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
),
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 290 },
enableSort: false,
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -195,7 +195,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'inodesUsed',
id: 'inodes_used',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-used">
Inodes Used
@@ -219,7 +219,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'inodesFree',
id: 'inodes_free',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-free">
Inodes Free

View File

@@ -26,48 +26,6 @@ export function formatBytes(bytes: number, decimals = 2): string {
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Returns stroke color for request utilization parameters according to current value
*/
export function getStrokeColorForRequestUtilization(value: number): string {
const percent = Number((value * 100).toFixed(1));
// Orange
if (percent <= 50) {
return Color.BG_AMBER_500;
}
// Green
if (percent > 50 && percent <= 100) {
return Color.BG_FOREST_500;
}
// Regular Red
if (percent > 100 && percent <= 150) {
return Color.BG_SAKURA_500;
}
// Dark Red
return Color.BG_CHERRY_600;
}
/**
* Returns stroke color for limit utilization parameters according to current value
*/
export function getStrokeColorForLimitUtilization(value: number): string {
const percent = Number((value * 100).toFixed(1));
// Green
if (percent <= 60) {
return Color.BG_FOREST_500;
}
// Yellow
if (percent > 60 && percent <= 80) {
return Color.BG_AMBER_200;
}
// Orange
if (percent > 80 && percent <= 95) {
return Color.BG_AMBER_500;
}
// Red
return Color.BG_SAKURA_500;
}
export const POD_STATUS_COLORS: Record<
InframonitoringtypesPodStatusDTO,
BadgeColor

View File

@@ -1,35 +1,11 @@
import { Progress } from '@signozhq/ui/progress';
import TanStackTable from 'components/TanStackTableView';
import {
getMemoryProgressColor,
getProgressColor,
} from 'container/InfraMonitoringHostsV2/constants';
import {
getStrokeColorForLimitUtilization,
getStrokeColorForRequestUtilization,
} from '../commonUtils';
import styles from './EntityProgressBar.module.scss';
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory' | 'disk';
function getStrokeColor(type: EntityProgressBarType, value: number): string {
switch (type) {
case 'limit':
return getStrokeColorForLimitUtilization(value);
case 'request':
return getStrokeColorForRequestUtilization(value);
case 'cpu':
return getProgressColor(Number((value * 100).toFixed(1)));
case 'memory':
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
case 'disk':
return getProgressColor(Number((value * 100).toFixed(1)));
default:
return getStrokeColorForRequestUtilization(value);
}
}
import {
EntityProgressBarType,
getStrokeColor,
} from './EntityProgressBar.utils';
export function EntityProgressBar({
value,

View File

@@ -0,0 +1,254 @@
import { Color } from '@signozhq/design-tokens';
export type EntityProgressBarType =
| 'cpu-request'
| 'cpu-limit'
| 'memory-request'
| 'memory-limit'
| 'cpu'
| 'memory'
| 'disk';
export interface EntityProgressThreshold {
matches: (percent: number) => boolean;
color: string;
range: string;
label: string;
description: string;
}
const CPU_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 50,
color: Color.BG_AMBER_500,
range: '≤ 50%',
label: 'Over-requested',
description:
'CPU usage is at most half of the request. The rest of the request stays reserved on the node.',
},
{
matches: (percent): boolean => percent <= 100,
color: Color.BG_FOREST_500,
range: '> 50% - 100%',
label: 'Right-sized',
description: 'CPU usage is close to the request and stays within it.',
},
{
matches: (percent): boolean => percent <= 150,
color: Color.BG_SAKURA_500,
range: '> 100% - 150%',
label: 'Over request',
description:
'CPU usage is above the request. The extra CPU is not guaranteed and depends on spare node capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_600,
range: '> 150%',
label: 'Request badly undersized',
description:
'CPU usage is more than 1.5x the request, so most of the CPU in use is not guaranteed.',
},
];
const CPU_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 60,
color: Color.BG_FOREST_500,
range: '≤ 60%',
label: 'Healthy',
description: 'CPU usage is well below the limit.',
},
{
matches: (percent): boolean => percent <= 80,
color: Color.BG_AMBER_200,
range: '> 60% - 80%',
label: 'Watch',
description: 'CPU usage is approaching the limit.',
},
{
matches: (percent): boolean => percent <= 95,
color: Color.BG_AMBER_500,
range: '> 80% - 95%',
label: 'Near limit',
description:
'CPU usage is close to the limit. Usage above the limit is throttled.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '> 95%',
label: 'At limit',
description:
'CPU usage is at the limit, so the container is likely being throttled.',
},
];
const MEMORY_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 50,
color: Color.BG_AMBER_500,
range: '≤ 50%',
label: 'Over-requested',
description:
'Memory usage is at most half of the request. The rest of the request stays reserved on the node.',
},
{
matches: (percent): boolean => percent <= 100,
color: Color.BG_FOREST_500,
range: '> 50% - 100%',
label: 'Right-sized',
description: 'Memory usage is close to the request and stays within it.',
},
{
matches: (percent): boolean => percent <= 150,
color: Color.BG_SAKURA_500,
range: '> 100% - 150%',
label: 'Over request',
description:
'Memory usage is above the request. The extra memory is not guaranteed and is reclaimed first under node memory pressure.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_600,
range: '> 150%',
label: 'Request badly undersized',
description:
'Memory usage is more than 1.5x the request, so most of the memory in use is not guaranteed.',
},
];
const MEMORY_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 60,
color: Color.BG_FOREST_500,
range: '≤ 60%',
label: 'Healthy',
description: 'Memory usage is well below the limit.',
},
{
matches: (percent): boolean => percent <= 80,
color: Color.BG_AMBER_200,
range: '> 60% - 80%',
label: 'Watch',
description: 'Memory usage is approaching the limit.',
},
{
matches: (percent): boolean => percent <= 95,
color: Color.BG_AMBER_500,
range: '> 80% - 95%',
label: 'Near limit',
description:
'Memory usage is close to the limit. Unlike CPU, memory is not throttled: reaching the limit ends in an OOM kill.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '> 95%',
label: 'At limit',
description:
'Memory usage is at the limit, so an OOM kill and container restart are likely.',
},
];
const CPU_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'CPU usage is well below the available capacity.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'CPU usage is high relative to the available capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '≥ 90%',
label: 'Critical',
description: 'CPU usage is close to the available capacity.',
},
];
const MEMORY_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'Memory usage is well below the available capacity.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'Memory usage is high relative to the available capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_500,
range: '≥ 90%',
label: 'Critical',
description:
'Memory usage is close to the available capacity. Unlike CPU, memory is not throttled: running out ends in an OOM kill.',
},
];
const DISK_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'Most of the volume is still free.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'Used space is high relative to the volume capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '≥ 90%',
label: 'Critical',
description: 'The volume is nearly full. Writes fail once no space is left.',
},
];
export const THRESHOLDS_BY_TYPE: Record<
EntityProgressBarType,
EntityProgressThreshold[]
> = {
'cpu-request': CPU_REQUEST_THRESHOLDS,
'cpu-limit': CPU_LIMIT_THRESHOLDS,
'memory-request': MEMORY_REQUEST_THRESHOLDS,
'memory-limit': MEMORY_LIMIT_THRESHOLDS,
cpu: CPU_THRESHOLDS,
memory: MEMORY_THRESHOLDS,
disk: DISK_THRESHOLDS,
};
export function getStrokeColorForPercent(
type: EntityProgressBarType,
percent: number,
): string {
const thresholds = THRESHOLDS_BY_TYPE[type];
const match = thresholds.find((threshold) => threshold.matches(percent));
return (match ?? thresholds[thresholds.length - 1]).color;
}
export function getStrokeColor(
type: EntityProgressBarType,
value: number,
): string {
return getStrokeColorForPercent(type, Number((value * 100).toFixed(1)));
}

View File

@@ -0,0 +1,39 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 320px;
text-align: left;
text-wrap: wrap;
margin-bottom: var(--spacing-1);
}
.threshold {
display: flex;
align-items: stretch;
gap: var(--spacing-4);
}
.swatch {
width: 3px;
border-radius: 1px;
flex-shrink: 0;
background-color: var(--ept-color);
}
.thresholdBody {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.thresholdHeading {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
}
.range {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,56 @@
import { Typography } from '@signozhq/ui/typography';
import {
EntityProgressBarType,
THRESHOLDS_BY_TYPE,
} from './EntityProgressBar.utils';
import styles from './EntityProgressThresholds.module.scss';
interface EntityProgressThresholdsProps {
type: EntityProgressBarType;
note?: string;
}
export function EntityProgressThresholds({
type,
note,
}: EntityProgressThresholdsProps): JSX.Element {
return (
<div
className={styles.container}
data-testid={`entity-progress-thresholds-${type}`}
>
{note && (
<Typography.Text as="p" size="small">
{note}
</Typography.Text>
)}
{THRESHOLDS_BY_TYPE[type].map((threshold) => (
<div key={threshold.range} className={styles.threshold}>
<span
className={styles.swatch}
style={{ '--ept-color': threshold.color } as React.CSSProperties}
/>
<div className={styles.thresholdBody}>
<div className={styles.thresholdHeading}>
<Typography.Text as="span" size="small" weight="medium">
{threshold.label}
</Typography.Text>
<Typography.Text
as="span"
size="small"
color="muted"
className={styles.range}
>
{threshold.range}
</Typography.Text>
</div>
<Typography.Text as="p" size="small" color="muted">
{threshold.description}
</Typography.Text>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import { THRESHOLDS_BY_TYPE } from '../EntityProgressBar.utils';
import { EntityProgressThresholds } from '../EntityProgressThresholds';
describe('EntityProgressThresholds', () => {
it('renders every threshold band for the given type', () => {
render(<EntityProgressThresholds type="cpu-limit" />);
expect(
screen.getByTestId('entity-progress-thresholds-cpu-limit'),
).toBeInTheDocument();
THRESHOLDS_BY_TYPE['cpu-limit'].forEach((threshold) => {
expect(screen.getByText(threshold.label)).toBeInTheDocument();
expect(screen.getByText(threshold.range)).toBeInTheDocument();
expect(screen.getByText(threshold.description)).toBeInTheDocument();
});
});
it('renders the note above the threshold bands when provided', () => {
render(
<EntityProgressThresholds type="memory" note="Excluding cache memory." />,
);
expect(screen.getByText('Excluding cache memory.')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,5 @@
export { EntityProgressBar } from './EntityProgressBar';
export { EntityProgressThresholds } from './EntityProgressThresholds';
export { ValidateColumnValueWrapper } from './ValidateColumnValueWrapper';
export { ExpandButtonWrapper } from './ExpandButtonWrapper';
export {

View File

@@ -1,8 +1,7 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
@@ -59,23 +58,6 @@ function renderEditPage(
describe('CreateEditRolePage - Feature Gate', () => {
describe('create mode - feature disabled', () => {
it('shows error when fine-grained authz flag is inactive', async () => {
renderCreatePage({
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
@@ -113,23 +95,6 @@ describe('CreateEditRolePage - Feature Gate', () => {
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const ROLE_NAME = 'test-role';
it('shows error when fine-grained authz flag is inactive', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });

View File

@@ -1,7 +1,6 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
@@ -33,26 +32,6 @@ describe('ViewRolePage - Feature Gate', () => {
});
describe('feature disabled', () => {
it('shows error when fine-grained authz flag is inactive', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows error when license is invalid', async () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),

View File

@@ -4,13 +4,7 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
defaultFeatureFlags,
render,
screen,
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { render, screen, userEvent } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
@@ -191,30 +185,6 @@ describe('RolesSettings', () => {
}
});
it('hides the create button and disables row clicks when fine-grained authz flag is inactive', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /custom role/i }),
).not.toBeInTheDocument();
const rows = document.querySelectorAll('.roles-table-row');
rows.forEach((row) => {
expect(row).not.toHaveClass('roles-table-row--clickable');
expect(row.getAttribute('role')).not.toBe('button');
});
});
it('hides the create button and disables row clicks when license is not valid', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: { activeLicense: invalidLicense },

View File

@@ -2,10 +2,15 @@
* This was introduced to fix a sync bug between Nuqs and react-router-dom
*
* We are using the wrong adapter for nuqs because the correct one only supports v6/v7,
* and we are at version v5. This causes the nuqs/react-router-dom to be out of sync.
* and we are at version v5. Nuqs writes params straight to the History API, which
* react-router v5 never observes, so `useLocation().search` (and `useUrlQuery()`) can
* be several nuqs updates behind the real URL.
*
* We can revert this commit once we migrate react-router-dom to v6, or once we migrate
* to DateTimeSelectionV3
* Use this whenever you need to build a navigation target on top of the current
* params, otherwise stale values get republished and nuqs adopts them back on its
* next flush (it snapshots `window.location.search`).
*
* We can revert this once we migrate react-router-dom to v6.
*/
/**

View File

@@ -1,11 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsInfraMonitoringV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find(
(flag) => flag.name === FeatureKeys.USE_INFRA_MONITORING_V2,
)?.active,
);
}

View File

@@ -1,4 +1,3 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
@@ -6,22 +5,12 @@ export const useRolesFeatureGate = (): {
isRolesEnabled: boolean;
isLoading: boolean;
} => {
const {
activeLicense,
featureFlags,
isFetchingActiveLicense,
isFetchingFeatureFlags,
} = useAppContext();
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
const isFineGrainedAuthzEnabled =
featureFlags?.find((f) => f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ)
?.active ?? false;
return {
isRolesEnabled: isValidLicense && isFineGrainedAuthzEnabled,
isLoading:
(isFetchingActiveLicense && !activeLicense) ||
(isFetchingFeatureFlags && !featureFlags),
isRolesEnabled: isValidLicense,
isLoading: isFetchingActiveLicense && !activeLicense,
};
};

View File

@@ -7,7 +7,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import getMinAgo from './getStartAndEndTime/getMinAgo';
const validCustomTimeRegex = /^(\d+)([mhdw])$/;
const validCustomTimeRegex = /^(\d+)(months?|[mhdw])$/;
export const isValidShortHandDateTimeFormat = (time: string): boolean =>
validCustomTimeRegex.test(time);

View File

@@ -27,9 +27,13 @@ export const slackTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status
export const slackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}} *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const googleChatTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`;
export const googleChatDescriptionDefaultValue = `{{ range .Alerts -}} **Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }} **Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }} **Description:** {{ .Annotations.description }}{{ end }} {{ end }}`;
export const editSlackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} dummy_summary *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const pagerDutyDescriptionDefaultVaule = `{{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ range .Alerts.Firing }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ range .Alerts.Resolved }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }}`;
export const pagerDutyDescriptionDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} {{- if gt (len .CommonLabels) (len .GroupLabels) -}} {{" "}}( {{- with .CommonLabels.Remove .GroupLabels.Names }} {{- range $index, $label := .SortedPairs -}} {{ if $index }}, {{ end }} {{- $label.Name }}="{{ $label.Value -}}" {{- end }} {{- end -}} ) {{- end }}`;
export const pagerDutyAdditionalDetailsDefaultValue = JSON.stringify({
firing: `{{ .Alerts.Firing | toJson }}`,

View File

@@ -10,6 +10,7 @@ import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import {
ChannelType,
GoogleChatChannel,
MsTeamsChannel,
PagerChannel,
SlackChannel,
@@ -59,11 +60,20 @@ function ChannelsEdit(): JSX.Element {
const prepChannelConfig = (): {
type: string;
channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel;
channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel;
} => {
let channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel = {
let channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel = {
name: '',
};
if (value && 'slack_configs' in value) {
const slackConfig = value.slack_configs[0];
channel = slackConfig;
@@ -81,6 +91,16 @@ function ChannelsEdit(): JSX.Element {
channel,
};
}
if (value && 'googlechat_configs' in value) {
const [googleChatConfig] = value.googlechat_configs;
channel = googleChatConfig;
return {
type: ChannelType.GoogleChat,
channel,
};
}
if (value && 'pagerduty_configs' in value) {
const pagerConfig = value.pagerduty_configs[0];
channel = pagerConfig;

View File

@@ -52,12 +52,12 @@ const makeDashboard = (
...overrides,
}) as unknown as DashboardListItem;
const renderRow = (dashboard: DashboardListItem): void => {
const renderRow = (dashboard: DashboardListItem, canEdit = true): void => {
render(
<DashboardRow
dashboard={dashboard}
index={0}
canEdit
canEdit={canEdit}
showUpdatedAt={false}
showUpdatedBy={false}
/>,
@@ -105,6 +105,19 @@ describe('DashboardRow', () => {
expect(mockSafeNavigate).not.toHaveBeenCalled();
expect(screen.getByTestId('legacy-dashboard-id')).toBeInTheDocument();
expect(
screen.getByTestId('legacy-dashboard-retry-migration'),
).toBeInTheDocument();
});
it('withholds the retry action from a row the user cannot edit', async () => {
renderRow(makeDashboard({ legacy: true }), false);
await userEvent.click(screen.getByTestId('dashboard-title-0'));
expect(
screen.queryByTestId('legacy-dashboard-retry-migration'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -252,6 +252,7 @@ function DashboardRow({
open={isLegacyDialogOpen}
dashboardId={id}
dashboardName={name}
canEdit={canEdit}
onClose={(): void => setIsLegacyDialogOpen(false)}
/>
)}

View File

@@ -49,6 +49,7 @@
.footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -18,19 +18,30 @@ jest.mock('container/Integrations/utils', () => ({
handleContactSupport: (isCloud: boolean): void => mockContactSupport(isCloud),
}));
const mockRetryMigration = jest.fn();
let isMigrating = false;
jest.mock('../../hooks/useRetryMigration', () => ({
useRetryMigration: (): {
retryMigration: jest.Mock;
isMigrating: boolean;
} => ({ retryMigration: mockRetryMigration, isMigrating }),
}));
const DASHBOARD_ID = '0f9a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c';
describe('LegacyDashboardDialog', () => {
beforeEach(() => {
jest.clearAllMocks();
isMigrating = false;
});
const setup = (open = true): void => {
const setup = ({ open = true, canEdit = true } = {}): void => {
render(
<LegacyDashboardDialog
open={open}
dashboardId={DASHBOARD_ID}
dashboardName="My Legacy Dashboard"
canEdit={canEdit}
onClose={jest.fn()}
/>,
);
@@ -57,8 +68,31 @@ describe('LegacyDashboardDialog', () => {
expect(mockContactSupport).toHaveBeenCalledTimes(1);
});
it('retries the migration for the dashboard', async () => {
setup();
await userEvent.click(screen.getByTestId('legacy-dashboard-retry-migration'));
expect(mockRetryMigration).toHaveBeenCalledWith(DASHBOARD_ID);
});
it('blocks retry and close while the migration is in flight', () => {
isMigrating = true;
setup();
expect(screen.getByTestId('legacy-dashboard-retry-migration')).toBeDisabled();
expect(screen.getByTestId('legacy-dashboard-close')).toBeDisabled();
});
it('offers only the support path without edit access', () => {
setup({ canEdit: false });
expect(
screen.queryByTestId('legacy-dashboard-retry-migration'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('legacy-dashboard-contact-support'),
).toBeInTheDocument();
});
it('renders nothing when closed', () => {
setup(false);
setup({ open: false });
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowUpRight, Copy } from '@signozhq/icons';
import { ArrowUpRight, Copy, RotateCw } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
@@ -9,28 +9,34 @@ import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
import { useRetryMigration } from '../../hooks/useRetryMigration';
import styles from './LegacyDashboardDialog.module.scss';
interface LegacyDashboardDialogProps {
open: boolean;
dashboardId: string;
dashboardName: string;
canEdit: boolean;
onClose: () => void;
}
/**
* Explains why a legacy (pre-v2) dashboard can't be opened in the new experience
* and hands the user the dashboard ID to share with support. Legacy rows are
* surfaced by the list API with `legacy: true` but have no v2 spec to render.
* and offers to re-run the migration. Legacy rows are surfaced by the list API
* with `legacy: true` but have no v2 spec to render. Retrying needs edit access,
* so viewers only get the dashboard ID to share with support.
*/
function LegacyDashboardDialog({
open,
dashboardId,
dashboardName,
canEdit,
onClose,
}: LegacyDashboardDialogProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { isCloudUser } = useGetTenantLicense();
const { retryMigration, isMigrating } = useRetryMigration(onClose);
const onCopyId = (): void => {
copyToClipboard(dashboardId);
@@ -49,6 +55,14 @@ function LegacyDashboardDialog({
});
};
const onRetryMigration = (): void => {
retryMigration(dashboardId);
void logEvent(DashboardListEvents.LegacyDialogAction, {
action: 'retryMigration',
dashboardId,
});
};
return (
<DialogWrapper
title="This dashboard isn't available in the new experience"
@@ -65,14 +79,15 @@ function LegacyDashboardDialog({
variant="ghost"
color="secondary"
size="md"
disabled={isMigrating}
onClick={onClose}
testId="legacy-dashboard-close"
>
Close
</Button>
<Button
variant="solid"
color="primary"
variant={canEdit ? 'outlined' : 'solid'}
color={canEdit ? 'secondary' : 'primary'}
size="md"
suffix={<ArrowUpRight size={14} />}
onClick={onContactSupport}
@@ -80,6 +95,20 @@ function LegacyDashboardDialog({
>
Contact Support
</Button>
{canEdit && (
<Button
variant="solid"
color="primary"
size="md"
prefix={<RotateCw size={14} />}
disabled={isMigrating}
loading={isMigrating}
onClick={onRetryMigration}
testId="legacy-dashboard-retry-migration"
>
Retry migration
</Button>
)}
</div>
}
>
@@ -87,8 +116,10 @@ function LegacyDashboardDialog({
<Typography.Text className={styles.description}>
<strong>{dashboardName || 'This dashboard'}</strong> hasn&apos;t been
migrated to the new dashboard experience yet, so it can&apos;t be opened
here. Share the dashboard ID below with support and we&apos;ll help you
move it over.
here.{' '}
{canEdit
? "Retrying the migration often works once we've handled the case that blocked it. If it still fails, share the dashboard ID below with support."
: "Share the dashboard ID below with support and we'll help you move it over."}
</Typography.Text>
<div className={styles.idField}>

View File

@@ -0,0 +1,109 @@
import { renderHook } from '@testing-library/react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
useMigrateDashboardV2,
} from 'api/generated/services/dashboard';
import { useRetryMigration } from '../useRetryMigration';
jest.mock('react-query', () => ({
useQueryClient: jest.fn(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useMigrateDashboardV2: jest.fn(),
invalidateListDashboardsForUserV2: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const queryClient = { invalidateQueries: jest.fn() };
const mockMutate = jest.fn();
const onMigrated = jest.fn();
type MutationHandlers = {
onSuccess: () => Promise<void>;
onError: (error: unknown) => void;
};
let captured: MutationHandlers;
// Stands in for the generated mutation hook: records the handlers the hook wires
// up so each one can be driven directly, and reports the requested in-flight state.
function setup(isLoading = false): {
retryMigration: (id: string) => void;
isMigrating: boolean;
} {
(useMigrateDashboardV2 as jest.Mock).mockImplementation(
(options: { mutation: MutationHandlers }) => {
captured = options.mutation;
return { mutate: mockMutate, isLoading };
},
);
return renderHook(() => useRetryMigration(onMigrated)).result.current;
}
// A 501 from GET/POST on an un-migrated dashboard carries the render error envelope.
const envelopeError = {
response: {
status: 501,
data: {
error: { code: 'dashboard_invalid_data', message: 'not in v6 schema' },
},
},
message: 'Request failed with status code 501',
};
// A gateway failure responds without an envelope, so there is no backend reason to show.
const bodylessError = {
response: { status: 502, data: '<html>bad gateway</html>' },
message: 'Request failed with status code 502',
};
describe('useRetryMigration', () => {
beforeEach(() => {
jest.clearAllMocks();
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
});
it('sends the dashboard id as a path parameter', () => {
setup().retryMigration('dash-1');
expect(mockMutate).toHaveBeenCalledWith({ pathParams: { id: 'dash-1' } });
});
it('refreshes the list, confirms with a toast and reports success', async () => {
setup();
await captured.onSuccess();
expect(invalidateListDashboardsForUserV2).toHaveBeenCalledWith(queryClient);
expect(toast.success).toHaveBeenCalledWith(
'Dashboard migrated to the new experience',
);
expect(onMigrated).toHaveBeenCalledTimes(1);
});
it('surfaces the backend reason and does not report success on failure', () => {
setup();
captured.onError(envelopeError);
expect(toast.error).toHaveBeenCalledWith('not in v6 schema');
expect(onMigrated).not.toHaveBeenCalled();
});
it('points the user at support when the failure carries no reason', () => {
setup();
captured.onError(bodylessError);
expect(toast.error).toHaveBeenCalledWith(
'Could not migrate this dashboard. Please contact support.',
);
});
it('reports the in-flight state from the mutation', () => {
expect(setup(true).isMigrating).toBe(true);
});
});

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateListDashboardsForUserV2,
useMigrateDashboardV2,
} from 'api/generated/services/dashboard';
import { toAPIError } from 'utils/errorUtils';
const FAILURE_MESSAGE =
'Could not migrate this dashboard. Please contact support.';
export interface UseRetryMigrationResult {
// Re-run the v1 to v2 migration for a dashboard.
retryMigration: (id: string) => void;
isMigrating: boolean;
}
// Wraps the retry-migration mutation for a legacy (pre-v2) dashboard: refreshes
// the personalized list so the row loses its legacy flag, and reports the
// backend's reason as a toast when the dashboard still can't be converted.
export function useRetryMigration(
onMigrated?: () => void,
): UseRetryMigrationResult {
const queryClient = useQueryClient();
const migrate = useMigrateDashboardV2({
mutation: {
onSuccess: async (): Promise<void> => {
await invalidateListDashboardsForUserV2(queryClient);
toast.success('Dashboard migrated to the new experience');
onMigrated?.();
},
onError: (error): void => {
toast.error(toAPIError(error, FAILURE_MESSAGE).getErrorMessage());
},
},
});
const retryMigration = useCallback(
(id: string): void => {
migrate.mutate({ pathParams: { id } });
},
[migrate],
);
return { retryMigration, isMigrating: migrate.isLoading };
}

View File

@@ -1,47 +1,15 @@
import { Suspense } from 'react';
import Loadable from 'components/Loadable';
import { TabRoutes } from 'components/RouteTab/types';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import InfraMonitoringHosts from 'container/InfraMonitoringHosts';
import InfraMonitoringK8s from 'container/InfraMonitoringK8s';
import { useIsInfraMonitoringV2 } from 'hooks/useIsInfraMonitoringV2';
import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2';
import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2';
import { Inbox } from '@signozhq/icons';
const InfraMonitoringHostsV2 = Loadable(
() => import('container/InfraMonitoringHostsV2'),
);
const InfraMonitoringK8sV2 = Loadable(
() => import('container/InfraMonitoringK8sV2'),
);
function HostsContainer(): JSX.Element {
const isInfraMonitoringV2 = useIsInfraMonitoringV2();
if (isInfraMonitoringV2) {
return (
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<InfraMonitoringHostsV2 />
</Suspense>
);
}
return <InfraMonitoringHosts />;
return <InfraMonitoringHostsV2 />;
}
function KubernetesContainer(): JSX.Element {
const isInfraMonitoringV2 = useIsInfraMonitoringV2();
if (isInfraMonitoringV2) {
return (
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<InfraMonitoringK8sV2 />
</Suspense>
);
}
return <InfraMonitoringK8s />;
return <InfraMonitoringK8sV2 />;
}
export const Hosts: TabRoutes = {

View File

@@ -150,13 +150,6 @@ export const defaultFeatureFlags = [
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.USE_FINE_GRAINED_AUTHZ,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
];
export function getAppContextMock(

View File

@@ -69,7 +69,12 @@ export const getMetricsExplorerUrl = ({
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
// `unit` must always be present: the query builder provider rewrites (and
// pushes a new history entry for) any compositeQuery missing a key of
// `initialQueriesMap`, which traps the browser back button.
// Since this is only being used by infra-monitoring, I will keep this fix one line
// instead of going and update each chart configuration.
encodeURIComponent(JSON.stringify({ unit: '', ...query })),
);
if (relativeTime) {

View File

@@ -85,6 +85,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
ID: "MigrateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Migrate dashboard to v2",
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},

View File

@@ -10,11 +10,8 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
)
@@ -76,14 +73,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUseFineGrainedAuthz,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether fine-grained authorization is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableAIObservability,
Kind: featuretypes.KindBoolean,
@@ -100,14 +89,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUseInfraMonitoringV2,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether infra monitoring v2 is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUsePrometheusClickhouseV2,
Kind: featuretypes.KindBoolean,

View File

@@ -63,6 +63,9 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
@@ -132,6 +135,8 @@ type Handler interface {
GetV2(http.ResponseWriter, *http.Request)
MigrateV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
ListForUserV2(http.ResponseWriter, *http.Request)

View File

@@ -207,6 +207,38 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) MigrateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.MigrateV2(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
@@ -19,7 +20,6 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
}
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
var storableDashboard *dashboardtypes.StorableDashboard
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
@@ -32,14 +32,13 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
if err != nil {
return err
}
storableDashboard = storable
return m.store.Create(ctx, storable)
})
if err != nil {
return nil, err
}
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromPostableDashboardV2(postable))
return dashboard, nil
}
@@ -121,6 +120,51 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
// Already migrated: return as-is.
if storable.IsV2() {
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// v1→v2 needs v5-shaped queries; run v4→v5 in place first.
transition.NewDashboardMigrateV5(module.settings.Logger(), nil, nil).Migrate(ctx, storable.Data)
v2, err := storable.ConvertV1ToV2()
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
if err != nil {
return err
}
v2.Tags = resolvedTags
storableV2, err := v2.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storableV2)
})
if err != nil {
return nil, err
}
return v2, nil
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err

View File

@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()", Alias: "spanCount"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIRequestModel,
Name: telemetrytypes.GenAIRequestModel,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIProviderName,
Name: telemetrytypes.GenAIProviderName,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
@@ -254,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
switch c.Type {
case qbtypes.ColumnTypeGroup:
switch c.Name {
case llmpricingruletypes.GenAIRequestModel:
case telemetrytypes.GenAIRequestModel:
modelIdx = i
case llmpricingruletypes.GenAIProviderName:
case telemetrytypes.GenAIProviderName:
providerIdx = i
}
case qbtypes.ColumnTypeAggregation:

View File

@@ -250,7 +250,7 @@ func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Reque
errs := []error{}
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
if item.Type == qbtypes.QueryTypeBuilder {
if item.Type == qbtypes.QueryTypeBuilder || item.Type == qbtypes.QueryTypeBuilderAI {
switch spec := item.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.Filter != nil && spec.Filter.Expression != "" {

View File

@@ -249,6 +249,7 @@ func (q *querier) buildPreviewProviders(
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
switch t {
case qbtypes.QueryTypeBuilder,
qbtypes.QueryTypeBuilderAI,
qbtypes.QueryTypePromQL,
qbtypes.QueryTypeClickHouseSQL,
qbtypes.QueryTypeTraceOperator:

View File

@@ -61,6 +61,7 @@ type querier struct {
// stay clean.
promV2 prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
@@ -89,6 +90,7 @@ func New(
promEngine prometheus.Prometheus,
promV2 prometheus.Prometheus,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
@@ -111,6 +113,7 @@ func New(
promEngine: promEngine,
promV2: promV2,
traceStmtBuilder: traceStmtBuilder,
aiTraceStmtBuilder: aiTraceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
@@ -295,6 +298,16 @@ func (q *querier) buildQueries(
}
queries[traceOpQuery.Name] = toq
steps[traceOpQuery.Name] = traceOpQuery.StepInterval
case qbtypes.QueryTypeBuilderAI:
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
@@ -361,6 +374,11 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
event.MetricsUsed = true
}
case qbtypes.QueryTypeBuilderAI:
filter := query.GetFilter()
event.FilterApplied = event.FilterApplied || (filter != nil && filter.Expression != "")
event.GroupByApplied = event.GroupByApplied || len(query.GetGroupBy()) > 0
event.TracesUsed = true
case qbtypes.QueryTypePromQL:
event.MetricsUsed = true
case qbtypes.QueryTypeTraceOperator:
@@ -923,7 +941,8 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
// reuse the original query's statement builder so an AI query keeps its AI builder
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
@@ -1280,6 +1299,8 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
if qe.GetStepInterval().Seconds() == 0 {
qe.SetStepInterval(secondsStep(metricRecommended))
}
case qbtypes.QueryTypeBuilderAI:
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
case qbtypes.QueryTypeTraceOperator:
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
}

View File

@@ -50,6 +50,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -123,6 +124,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
&mockMetricStmtBuilder{},

View File

@@ -21,6 +21,7 @@ func NewFactory(
promV2 prometheus.Prometheus,
metadataStore telemetrytypes.MetadataStore,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
@@ -43,6 +44,7 @@ func NewFactory(
prometheus,
promV2,
traceStmtBuilder,
aiTraceStmtBuilder,
logStmtBuilder,
auditStmtBuilder,
metricStmtBuilder,

View File

@@ -1599,15 +1599,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
@@ -1617,15 +1608,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
infraMonitoringV2 := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseInfraMonitoringV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
Active: infraMonitoringV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
@@ -117,6 +118,8 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
ctx := context.Background()
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
@@ -128,7 +131,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
bucketCache := querier.NewBucketCache(providerSettings, cache, 0, 0)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)

View File

@@ -42,6 +42,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
metricStmtBuilder,
@@ -77,6 +78,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
logStmtBuilder,
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -113,6 +115,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // prometheus
nil, // promV2
traceStmtBuilder,
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder

View File

@@ -49,12 +49,17 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
}
return rawPath + " IS NULL", nil
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumFixedString,
schema.ColumnTypeEnumDateTime64:
schema.ColumnTypeEnumFixedString:
if exists {
return comparison("<>", "''"), nil
}
return comparison("=", "''"), nil
case schema.ColumnTypeEnumDateTime64:
zero := fmt.Sprintf("toDateTime64(0, %d)", column.Type.(schema.DateTime64ColumnType).Precision)
if exists {
return comparison("<>", zero), nil
}
return comparison("=", zero), nil
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:

View File

@@ -8,6 +8,7 @@ import (
"regexp"
"strconv"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -194,6 +195,16 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
return tblFieldName, value
}
// ColumnIsTemporal reports whether a column carries a time value.
func ColumnIsTemporal(col *schema.Column) bool {
switch col.Type.GetType() {
case schema.ColumnTypeEnumDateTime64, schema.ColumnTypeEnumDateTime,
schema.ColumnTypeEnumDate, schema.ColumnTypeEnumDate32:
return true
}
return false
}
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }

View File

@@ -0,0 +1,166 @@
package querybuilder
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
// SplitFilterForAggregates partitions a filter expression on the top-level AND into a
// span-level part (WHERE) and a trace-level part (HAVING over per-trace aggregates).
// A key is trace-level when it carries the trace field context or its bare name is in
// aggregateNames; any other explicit context is span-level. An OR mixing the two
// classes is an error.
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
if strings.TrimSpace(query) == "" {
return "", "", nil
}
tree, syntaxErrors := parseFilterQuery(query)
if len(syntaxErrors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d syntax errors while parsing the filter expression.",
len(syntaxErrors),
)
additionals := make([]string, 0, len(syntaxErrors))
for _, syntaxError := range syntaxErrors {
if syntaxError.Error() != "" {
additionals = append(additionals, syntaxError.Error())
}
}
// TODO: add troubleshooting link to the filter query syntax guide once it's published.
return "", "", combinedErrors.WithAdditional(additionals...)
}
s := filterSplitter{query: []rune(query), aggregateNames: aggregateNames}
s.visit(tree)
if s.mixed {
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"trace-level and span-level filters cannot be combined within an OR/NOT group; separate them with a top-level AND")
}
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
}
func parseFilterQuery(query string) (antlr.Tree, []*SyntaxErr) {
lexerErrorListener := NewErrorListener()
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
parserErrorListener := NewErrorListener()
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
tree := parser.Query()
return tree, append(lexerErrorListener.SyntaxErrors, parserErrorListener.SyntaxErrors...)
}
// filterSplitter flattens the top-level AND chain and routes each atom to the span or
// having bucket by the class of the keys it references.
type filterSplitter struct {
query []rune
aggregateNames map[string]struct{}
span []string
having []string
mixed bool
}
func (s *filterSplitter) visit(node antlr.Tree) {
switch n := node.(type) {
case *grammar.QueryContext:
if n.Expression() != nil {
s.visit(n.Expression())
}
case *grammar.ExpressionContext:
if n.OrExpression() != nil {
s.visit(n.OrExpression())
}
case *grammar.OrExpressionContext:
// a real OR is kept whole so a class-mixing OR can be rejected
if ands := n.AllAndExpression(); len(ands) == 1 {
s.visit(ands[0])
} else {
s.route(n)
}
case *grammar.AndExpressionContext:
for _, u := range n.AllUnaryExpression() {
s.visit(u)
}
case *grammar.UnaryExpressionContext:
if n.NOT() != nil {
s.route(n)
} else if n.Primary() != nil {
s.visit(n.Primary())
}
case *grammar.PrimaryContext:
if n.OrExpression() != nil { // parenthesized sub-expression
s.visit(n.OrExpression())
} else {
s.route(n)
}
}
}
// route classifies an atom and appends its original source text to the right bucket.
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
if isTrace && isSpan {
s.mixed = true
return
}
text := atomSourceText(s.query, atom)
// re-wrap an OR group (its source slice excludes the enclosing parens) so the
// " AND " rejoin cannot invert OR/AND precedence
if or, ok := atom.(*grammar.OrExpressionContext); ok && len(or.AllAndExpression()) > 1 {
text = "(" + text + ")"
}
if isTrace {
s.having = append(s.having, text)
} else {
s.span = append(s.span, text)
}
}
// classifyKeys reports whether a subtree references trace-level and/or span-level
// keys. An unknown name under the trace context stays trace-level so the aggregate
// validation rejects it with a targeted error.
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
kc, ok := node.(*grammar.KeyContext)
if ok {
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
switch key.FieldContext {
case telemetrytypes.FieldContextTrace:
isTrace = true
case telemetrytypes.FieldContextUnspecified:
_, isTrace = aggregateNames[key.Name]
isSpan = !isTrace
default:
isSpan = true
}
return
}
for i := 0; i < node.GetChildCount(); i++ {
t, s := classifyKeys(node.GetChild(i), aggregateNames)
isTrace = isTrace || t
isSpan = isSpan || s
}
return
}
// atomSourceText slices the input by token offsets to preserve whitespace (the token
// stream drops it, gluing word operators to operands). ANTLR offsets are rune indices,
// hence the rune slice.
func atomSourceText(query []rune, atom antlr.ParserRuleContext) string {
start, stop := atom.GetStart(), atom.GetStop()
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
return atom.GetText()
}
return string(query[start.GetStart() : stop.GetStop()+1])
}

View File

@@ -0,0 +1,219 @@
package querybuilder
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSplitFilterForAggregates(t *testing.T) {
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
type tc struct {
name string
query string
span string // expected span-level (WHERE) part; "" => empty
having string // expected trace-level (HAVING) part; "" => empty
wantErr bool
}
cases := []tc{
// --- empty input ---------------------------------------------------------
{
name: "empty",
},
{
name: "whitespace only",
query: " ",
},
// --- single class --------------------------------------------------------
{
name: "span only",
query: "service.name = 'x'",
span: "service.name = 'x'",
},
{
name: "agg only bare",
query: "completion_tokens > 1000",
having: "completion_tokens > 1000",
},
{
// the user-facing `trace.` prefix marks a trace-level aggregate.
name: "agg only trace prefix",
query: "trace.completion_tokens > 1000",
having: "trace.completion_tokens > 1000",
},
{
// routes trace-level so aggregate validation rejects it with a targeted error
name: "unknown aggregate under trace context stays trace-level",
query: "trace.not_an_aggregate > 1000",
having: "trace.not_an_aggregate > 1000",
},
{
// ANTLR token offsets are rune indices; slicing must not shift after a multi-byte char
name: "unicode value before the split",
query: "service.name = 'héllo' AND completion_tokens > 1000",
span: "service.name = 'héllo'",
having: "completion_tokens > 1000",
},
// --- top-level AND splits across the two buckets -------------------------
{
name: "span AND agg",
query: "service.name = 'x' AND completion_tokens > 1000",
span: "service.name = 'x'",
having: "completion_tokens > 1000",
},
{
// order within a bucket is preserved; the two span atoms join with AND.
name: "span AND span AND agg",
query: "service.name = 'x' AND kind_string = 'Internal' AND completion_tokens > 1000",
span: "service.name = 'x' AND kind_string = 'Internal'",
having: "completion_tokens > 1000",
},
{
// a parenthesized top-level AND still splits across the two buckets.
name: "parenthesized span AND agg",
query: "(service.name = 'x' AND completion_tokens > 1000)",
span: "service.name = 'x'",
having: "completion_tokens > 1000",
},
// --- OR groups are re-wrapped in parens so a later AND-join can't invert
// precedence (`a AND (b OR c)` must not flatten to `a AND b OR c`) ------
{
name: "agg OR agg",
query: "completion_tokens > 1000 OR span_count > 3",
having: "(completion_tokens > 1000 OR span_count > 3)",
},
{
name: "span OR span",
query: "service.name = 'x' OR kind_string = 'Internal'",
span: "(service.name = 'x' OR kind_string = 'Internal')",
},
{
name: "span AND (span OR span)",
query: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
span: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
},
{
name: "agg AND (agg OR agg)",
query: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
having: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
},
{
// the OR group routes to span, the trailing aggregate to having.
name: "span AND (span OR span) AND agg",
query: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z') AND completion_tokens > 1000",
span: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z')",
having: "completion_tokens > 1000",
},
// --- a nested AND group flattens across the buckets (no spurious parens) --
{
name: "(span AND agg) AND agg",
query: "(service.name = 'x' AND completion_tokens > 1000) AND prompt_tokens > 5",
span: "service.name = 'x'",
having: "completion_tokens > 1000 AND prompt_tokens > 5",
},
// --- NOT wrapping a single-class group is routed whole to that class ------
{
name: "not agg",
query: "NOT (completion_tokens > 1000)",
having: "NOT (completion_tokens > 1000)",
},
{
name: "not span",
query: "NOT (service.name = 'x')",
span: "NOT (service.name = 'x')",
},
// --- an explicit non-trace context escapes the aggregate-alias shadow -----
{
// a span attribute named like an aggregate stays reachable via `attribute.`.
name: "attribute prefix on aggregate name routes span-level",
query: "attribute.completion_tokens > 5",
span: "attribute.completion_tokens > 5",
},
{
name: "span prefix on aggregate name routes span-level",
query: "span.completion_tokens > 5",
span: "span.completion_tokens > 5",
},
{
name: "prefixed attribute AND bare aggregate split across buckets",
query: "attribute.completion_tokens > 5 AND completion_tokens > 1000",
span: "attribute.completion_tokens > 5",
having: "completion_tokens > 1000",
},
// --- class-mixing is rejected in an OR group, a NOT group, or a nested OR -
{
name: "agg OR span rejected",
query: "completion_tokens > 1000 OR service.name = 'x'",
wantErr: true,
},
{
name: "not mixed rejected",
query: "NOT (completion_tokens > 1000 AND service.name = 'x')",
wantErr: true,
},
{
name: "span AND (agg OR span) rejected",
query: "service.name = 'x' AND (completion_tokens > 1000 OR kind_string = 'Client')",
wantErr: true,
},
// --- syntax errors are rejected, not silently dropped by error recovery ---
{
// recovery would yield an empty tree → both buckets empty → filter ignored.
name: "lone paren rejected",
query: ")",
wantErr: true,
},
{
name: "unbalanced parens rejected",
query: "((",
wantErr: true,
},
{
name: "bare operator rejected",
query: "AND",
wantErr: true,
},
{
// lexer-level error: recovery drops the whole expression.
name: "unterminated quote rejected",
query: "'unterminated",
wantErr: true,
},
{
// recovery would keep the rest — a partially applied filter with no error
name: "garbage atom alongside valid agg rejected",
query: ") AND completion_tokens > 5",
wantErr: true,
},
{
name: "missing value rejected",
query: "completion_tokens >",
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
span, having, err := SplitFilterForAggregates(c.query, agg)
if c.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, c.span, span, "span part")
assert.Equal(t, c.having, having, "having part")
})
}
}

View File

@@ -18,6 +18,17 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
}
}
// Rewrite rewrites and validates a HAVING expression against a caller-supplied column
// map (user-facing name -> SQL identifier). Values are inlined, so the result is a
// bare boolean expression with no bound args.
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {
return "", nil
}
r.columnMap = columnMap
return r.rewriteAndValidate(expression)
}
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {

View File

@@ -82,6 +82,9 @@ func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.Variable
switch queryType {
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
case qbtypes.QueryTypeBuilderAI.StringValue():
// always a traces query; the signal may be absent from the payload
return builderQueryResourceRefs(queryType, coretypes.ResourceTelemetryResourceTraces, query.Get("spec"), variables)
case qbtypes.QueryTypePromQL.StringValue():
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
case qbtypes.QueryTypeClickHouseSQL.StringValue():
@@ -103,7 +106,10 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
if err != nil {
return nil, err
}
return builderQueryResourceRefs(queryType, resource, spec, variables)
}
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
if err != nil {
return nil, err

Some files were not shown because too many files have changed in this diff Show More