Compare commits

..

29 Commits

Author SHA1 Message Date
Abhi Kumar
1622868b0d feat(dashboards-v2): drill down filter-by-value into the View modal 2026-06-30 00:55:49 +05:30
Abhi Kumar
25e91f1dcc feat(dashboards-v2): open a drilldown context menu with View in Logs/Traces 2026-06-30 00:55:37 +05:30
Abhi Kumar
6a879e80bc feat(dashboards-v2): declare drilldown as a per-kind panel capability 2026-06-30 00:55:17 +05:30
Abhi Kumar
fa8c035d75 feat(dashboards-v2): add panel drilldown click-enrichment utils 2026-06-30 00:55:03 +05:30
Abhi Kumar
fa2d0387d2 feat(dashboards-v2): wire up the panel View action
Host a single ViewPanelModal at the layout level, driven by the URL, and
point the panel menu's "View" action at openView so it opens the modal
for that panel. Replaces the not-implemented placeholder; test updated to
assert the action opens the modal.
2026-06-29 00:17:46 +05:30
Abhi Kumar
41615397c8 feat(dashboards-v2): add the panel View modal
The View modal UI — a compact, single-host drilldown editor matching V1's
FullView: a draft preview over the per-view window, a toolbar (Reset
Query, Switch to Edit Mode, panel-type selector, time picker, refresh),
and the query-builder-only drilldown editor (no ClickHouse/PromQL tabs,
per V1). Edits are temporary — they never touch the saved dashboard.
2026-06-29 00:16:48 +05:30
Abhi Kumar
d7ea516df5 feat(dashboards-v2): add View-modal state and editor hooks
The logic layer for the panel View modal:
- useViewPanel drives open/close off the expandedWidgetId URL param (V1
  param parity; shareable, refresh-safe, back-button closes), and clears
  the drilldown editor's URL state on close.
- useViewPanelTimeWindow gives the modal a per-view time window isolated
  from the dashboard's global time.
- useViewPanelEditor turns the modal into a drilldown editor on top of
  usePanelEditSession, adding the saved-query snapshot for Reset and the
  builder signal for the panel-type selector.
2026-06-29 00:16:30 +05:30
Abhi Kumar
204c713236 feat(dashboards-v2): render the graph-manager legend in standalone view
In STANDALONE_VIEW the time-series and bar renderers now show V1's
graph-manager below the chart (Filter Series + per-series show/hide +
Save), with series visibility persisting to localStorage. A new
onCloseStandaloneView interaction threads the modal's close through
PanelBody so the manager's Save/Cancel can dismiss the view.
2026-06-29 00:15:38 +05:30
Abhi Kumar
522c42de53 refactor(dashboards-v2): ready ChartManager for the standalone view
Prep the shared graph-manager so it renders cleanly inside the View
modal: swap the legacy useNotifications save toast for the sonner toast,
and let ChartLayout grow to its content height (auto) so the manager's
legend table isn't clipped under the chart.
2026-06-29 00:14:20 +05:30
Abhi Kumar
af450bf330 feat(dashboards-v2): let the panel editor open on a handed-off spec
useOpenPanelEditor now accepts optional router location state, and the
editor page opens on a handed-off spec (PanelEditorHandoffState.editSpec)
when present, falling back to the saved panel. This lets the View modal's
drilldown edits flow into "Switch to Edit Mode" without persisting them.
The handoff lives in router state, so a refresh / new tab cleanly reverts
to the saved panel.
2026-06-29 00:13:32 +05:30
Abhi Kumar
9a2b3c7daf refactor(dashboards-v2): extract usePanelEditSession shared editing pipeline
Pull the panel editor's draft + query + staged-query sync + kind-switch
wiring into a single usePanelEditSession hook so the full-page editor and
the upcoming View modal run the same pipeline and can't drift. The
container now layers only its save/list-seeding concerns on top.

A characterization test (arg-capturing mocks of the leaf hooks) locks the
editor's forwarding behavior so the extraction is provably
behavior-preserving.
2026-06-29 00:13:07 +05:30
Abhi Kumar
d8f91a6c7c feat(dashboards-v2): add a Create alert rule action to the panel editor
Add an "Actions" group at the foot of the editor config pane — a list of
cross-page navigation links kept distinct from the collapsible config
sections above. The first action, "Create alert rule", reuses
useCreateAlertFromPanel to seed an alert from the draft query; the group
hides for kinds that can't seed an alert and scales to more actions.

ConfigPane now derives the panel kind from its spec and takes the draft
panel + panelId so the group can build the link.
2026-06-27 23:03:31 +05:30
Abhi Kumar
a5694ae179 feat(dashboards-v2): create alerts from a dashboard panel
Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.
2026-06-27 23:02:57 +05:30
Abhi Kumar
a8fb48fd72 feat(dashboards-v2): gate the panel editor through the capability guard
Route the panel editor's query builder and visualization type switcher through
the capability guard instead of V1's PANEL_TYPE_TO_QUERY_TYPES (now no longer
imported by any V2 file):

- PanelEditorQueryBuilder is keyed on PanelKind; its query-type tabs and
  query-builder field visibility come from the guard.
- Switching the panel kind coerces the active query type via the guard.
- The visualization type switcher disables a kind when the active query type or
  datasource is incompatible with it (e.g. List under ClickHouse/PromQL, or List
  with a metrics query). The live query type is read from the query-builder
  provider so a not-yet-staged new panel still gates correctly, and a tooltip
  explains why a type is disabled. ConfigSelect gains opt-in per-option tooltips.
2026-06-26 17:56:53 +05:30
Abhi Kumar
c8c424c788 feat(dashboards-v2): add a panel capability guard
Centralize "what works with what" for V2 dashboard panels into one
deterministic guard. Each panel kind declares its supported query types and
optional query-builder field rules alongside its existing supported signals; a
pure `capabilities` module reads the panel registry to answer panel x
query-type x signal validity, coerce an unsupported query type, and resolve the
query-builder fields a kind hides.

`supportedQueryTypes` is required, so the registry's mapped type forces every
present and future kind to declare it. This re-homes the panel->query-type
compatibility that V2 previously imported from V1's NewWidget/utils into V2 land.

No behavior change: no consumer is wired to the guard yet.
2026-06-26 17:56:02 +05:30
Abhi Kumar
833aeda808 chore(dashboards-v2): tidy panel-editor query helpers
- useLegendSeries: drop redundant optional chaining on panel.spec.
- Remove the unused getPanelKindLabel util.
2026-06-26 17:55:32 +05:30
Abhi Kumar
898209b5e5 refactor(dashboards-v2): rename Formatting section to "Formatting & Units"
Serialize section header test ids (lowercase, spaces → dashes) so a
multi-word title doesn't break the data-testid, and update the test.
2026-06-26 17:55:32 +05:30
Abhi Kumar
9a97b3a623 feat(dashboards-v2): redesign span-gaps as a "Disconnect values" control
Replace the raw seconds input with a Never/Threshold toggle plus a
duration "Threshold value" field. The threshold is stored verbatim as a
duration string ("10m", "5s") — the wire format the backend expects — and
parsed back to seconds only for rendering and validation. Threads the
query step interval through the config pane to seed/floor the threshold,
and rejects invalid or below-step-interval entries inline (V1 parity).
2026-06-26 17:55:32 +05:30
Abhi Kumar
672524adcc fix(dashboards-v2): allow clearing the threshold value input
The threshold "Value" field was a controlled numeric input, so an
emptied field snapped back to 0 (Number("") is 0, not NaN) and the
seeded 0 could never be removed. Hold a local string so the field can
be cleared and edited; shared by all threshold row variants.
2026-06-26 17:55:32 +05:30
Abhi Kumar
f4c3fedb03 feat(dashboards-v2): add "Move out of section" panel action
The "Move to section" submenu only listed titled sections, so a panel
in a titled section couldn't be moved back to the untitled root. Add a
direct "Move out of section" action, shown when the panel sits in a
titled section and an untitled root section exists to receive it.
2026-06-26 17:55:32 +05:30
Abhi Kumar
caf75b097f fix(dashboards-v2): place toolbar-created panels in the root section
The top-right "New Panel" button creates a panel with no section context,
which createPanelOps resolved to the LAST section instead of the root.
Fall back to the first (root) section when no valid index is given; still
create an untitled section when the dashboard has none.
2026-06-26 17:55:32 +05:30
Abhi Kumar
5f649e0d9e fix(dashboards-v2): disable panel types unsupported by the datasource
A new panel's builder is seeded with the kind's default signal, but
`spec.queries` stays empty until the query is modified — so the type
switcher saw an undefined datasource and never disabled incompatible
types (e.g. List on a metrics panel, which then breaks rendering).
Resolve the signal with a fallback to the kind's default signal so
compatibility is enforced from the first render.
2026-06-26 17:55:32 +05:30
Abhi Kumar
c2f347d3f4 style(dashboards-v2): format getSwitchedPluginSpec test with oxfmt
Wrap the long getSwitchedPluginSpec(...) calls so the file passes
`oxfmt --check` (the fmt / js CI gate). Formatting only, no behavior change.
2026-06-26 17:54:23 +05:30
Abhi Kumar
f3eda0956a feat(dashboards-v2): reuse findFreeSlot for panel clone placement
Clone now places the copy beside the last row when it fits, else wraps
to a fresh row at the section bottom — matching the new-panel save path
instead of always starting a new row.
2026-06-26 17:54:23 +05:30
Abhi Kumar
d31255e717 feat(dashboards-v2): wire the type switcher into the config pane
Render the switcher in the Visualization section, forward the panel
kind + datasource signal + switch handler through SectionSlot/registry,
and declare a Visualization section on every panel kind.
2026-06-26 17:54:23 +05:30
Abhi Kumar
34c90b1289 refactor(dashboards-v2): component-based icons in the panel-type modal
Store icon components (not pre-rendered elements) in PANEL_TYPES so each
consumer controls sizing; rename constants.tsx to constants.ts now that
it holds no JSX.
2026-06-26 17:54:23 +05:30
Abhi Kumar
4bb32a69e5 feat(dashboards-v2): add the PanelTypeSwitcher control
A ConfigSelect-based control listing the panel kinds, disabling types
whose supported signals exclude the current datasource.
2026-06-26 15:31:29 +05:30
Abhi Kumar
8eb299e8fa feat(dashboards-v2): add panel-type switch logic
Add getSwitchedPluginSpec (a reversible per-kind plugin-spec transform)
and the usePanelTypeSwitch hook that rebuilds the builder query and spec
when the panel kind changes, guarding null queries.
2026-06-26 15:31:29 +05:30
Abhi Kumar
5f39cd0038 refactor(dashboards-v2): extend ConfigSelect for the type switcher
Allow ConfigSelect items to carry an arbitrary icon node and a disabled
flag, and align the ConfigSegmented styling — the building blocks the
panel-type switcher needs.
2026-06-26 15:31:29 +05:30
1402 changed files with 72120 additions and 13385 deletions

View File

@@ -70,11 +70,7 @@ jobs:
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-browsers
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \

View File

@@ -56,6 +56,17 @@ jobs:
PRIMUS_REF: main
JS_SRC: frontend
JS_PKG_MANAGER: pnpm
languages:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: run
run: bash frontend/scripts/validate-md-languages.sh
openapi:
if: |
github.event_name == 'merge_group' ||

View File

@@ -29,8 +29,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -121,9 +119,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return implmetricreductionrule.NewModule()
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},

View File

@@ -24,7 +24,6 @@ import (
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
@@ -47,7 +46,6 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -184,9 +182,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
},
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, lic licensing.Licensing, flgr pkgflagger.Flagger, ms telemetrytypes.MetadataStore, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, lic, flgr, ms, ps, threads)
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
},

View File

@@ -141,10 +141,6 @@ querier:
flux_interval: 5m
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
##################### TelemetryStore #####################
telemetrystore:

File diff suppressed because it is too large Load Diff

View File

@@ -36,55 +36,6 @@ var (
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
### Message
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
### Url
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
```go
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
WithUrl("https://signoz.io/docs/...")
```
### Additional
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
#### Message
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
#### Suggestions
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithAdditional("field `field` not found")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
```
### Retry
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
```go
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
WithRetryAfter(5 * time.Second)
```
### Suggestions
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
```go
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
```
## Show me some examples
### Using the error

View File

@@ -143,10 +143,6 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
return provider.pkgAuthzService.Collect(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
@@ -374,7 +370,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
for _, cb := range provider.onBeforeRoleDelete {
if err := cb(ctx, orgID, id, role.Name); err != nil {
if err := cb(ctx, orgID, id); err != nil {
return err
}
}

View File

@@ -290,10 +290,6 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}

View File

@@ -1,565 +0,0 @@
package implmetricreductionrule
import (
"context"
"slices"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
)
var (
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
)
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
type volumeRow struct {
MetricName string
Ingested uint64
Reduced uint64
}
type volumePoint struct {
TimestampMs int64
Ingested uint64
Reduced uint64
}
type clickhouse struct {
telemetryStore telemetrystore.TelemetryStore
threads int
}
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
}
func (c *clickhouse) withThreads(ctx context.Context) context.Context {
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
}
func floorToTimeSeriesBucket(ms int64) int64 {
return ms - (ms % timeSeriesBucketMilli)
}
func strictEffectiveFrom(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
names := make([]any, 0, len(metricNames))
froms := make([]any, 0, len(metricNames))
for _, name := range metricNames {
names = append(names, name)
froms = append(froms, effectiveFrom[name])
}
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(froms) + ", 0)"
}
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
ctx = c.withThreads(ctx)
ib := sqlbuilder.NewInsertBuilder()
ib.InsertInto(reductionRulesTable)
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
}
return nil
}
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("attr_name")
sb.Distinct()
sb.From(metadataTable)
sb.Where(
sb.E("metric_name", metricName),
"NOT startsWith(attr_name, '__')",
sb.GE("last_reported_unix_milli", startMs),
sb.LE("first_reported_unix_milli", endMs),
)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attribute keys")
}
defer rows.Close()
keys := make([]string, 0)
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
}
keys = append(keys, key)
}
return keys, rows.Err()
}
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
startMs = floorToTimeSeriesBucket(startMs)
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {
if i > 0 {
reducedExpr += ", "
}
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
}
reducedExpr += "))"
}
sb.Select("uniq(fingerprint)", reducedExpr)
sb.From(bufferSeriesTable)
conds := []string{
sb.E("metric_name", metricName),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
sb.E("is_reduced", false),
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var current, reduced uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&current, &reduced); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
}
if len(keptLabels) == 0 && current == 0 {
reduced = 0
}
if reduced > current {
reduced = current
}
return current, reduced, nil
}
// VolumeByMetric returns ingested vs reduced series counts per metric.
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reduced, err := c.reducedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
out := make(map[string]volumeRow, len(metricNames))
for metricName, count := range ingested {
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
}
for metricName, count := range reduced {
row := out[metricName]
row.MetricName = metricName
row.Reduced = count
out[metricName] = row
}
return out, nil
}
// ingestedSeriesCount counts distinct raw fingerprints per metric from the samples buffer over the
// window.
func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// reducedSeriesCount counts distinct reduced_fingerprints per metric, summed across the two 60s
// reduced sample tables.
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
out := make(map[string]uint64, len(metricNames))
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for metricName, count := range counts {
out[metricName] += count
}
}
return out, nil
}
func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(reduced_fingerprint)")
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// RankByVolume ranks metrics by ingested/reduced series volume. Like VolumeByMetric, the counts read
// the samples tables with a strict effective_from gate; the reduced count sums distinct
// reduced_fingerprints across the two 60s reduced sample tables.
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
if len(metricNames) == 0 {
return []volumeRow{}, nil
}
ctx = c.withThreads(ctx)
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
sb := sqlbuilder.NewSelectBuilder()
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
sb.OrderBy(orderExpr + " " + direction)
if limit > 0 {
sb.Limit(limit).Offset(offset)
}
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to rank reduction rules by volume")
}
defer rows.Close()
out := make([]volumeRow, 0, len(metricNames))
for rows.Next() {
var row volumeRow
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
}
out = append(out, row)
}
return out, rows.Err()
}
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
if len(metricNames) == 0 {
return 0, 0, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
return ingested, min(last+sum, ingested), nil
}
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count()")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
}
return count, nil
}
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
sb.Select("uniq(reduced_fingerprint, unix_milli)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
}
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range reduced {
retained[ts] += count
}
}
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
}
return mergeVolumePoints(ingested, retained), nil
}
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
buckets := make(map[int64]struct{}, len(ingested))
for ts := range ingested {
buckets[ts] = struct{}{}
}
for ts := range reduced {
buckets[ts] = struct{}{}
}
timestamps := make([]int64, 0, len(buckets))
for ts := range buckets {
timestamps = append(timestamps, ts)
}
slices.Sort(timestamps)
points := make([]volumePoint, 0, len(timestamps))
for _, ts := range timestamps {
points = append(points, volumePoint{
TimestampMs: ts,
Ingested: ingested[ts],
Reduced: reduced[ts],
})
}
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to bucket series by time")
}
defer rows.Close()
out := make(map[int64]uint64)
for rows.Next() {
var (
ts int64
count uint64
)
if err := rows.Scan(&ts, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series bucket")
}
out[ts] = count
}
return out, rows.Err()
}

View File

@@ -1,571 +0,0 @@
package implmetricreductionrule
import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
)
type module struct {
store metricreductionruletypes.Store
ch *clickhouse
dashboard dashboard.Module
ruleStore ruletypes.RuleStore
licensing licensing.Licensing
flagger flagger.Flagger
metadataStore telemetrytypes.MetadataStore
logger *slog.Logger
}
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, metadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
return &module{
store: NewStore(sqlStore),
ch: newClickhouse(telemetryStore, threads),
dashboard: dashboardModule,
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
licensing: licensing,
flagger: flagger,
metadataStore: metadataStore,
logger: scoped.Logger(),
}
}
func (m *module) checkAccess(ctx context.Context, orgID valuer.UUID) error {
if !m.flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported, "metric volume control is not enabled")
}
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
}
return nil
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := params.Validate(); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
default:
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
}
}
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
domainRules, total, err := m.store.List(ctx, orgID, params)
if err != nil {
return nil, err
}
metricNames := make([]string, len(domainRules))
effectiveFrom := make(map[string]int64, len(domainRules))
for i, rule := range domainRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search, MetricName: params.MetricName})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
ruleByMetric := make(map[string]*metricreductionruletypes.ReductionRule, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
ruleByMetric[rule.MetricName] = rule
}
ranked, err := m.ch.RankByVolume(ctx, metricNames, effectiveFrom, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
}
now := time.Now()
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Create(ctx, rule); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
rule, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
existing, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
existing.MatchType = req.MatchType
existing.Labels = metricreductionruletypes.LabelList(req.Labels)
existing.EffectiveFrom = now.Add(effectiveFromMargin)
existing.UpdatedAt = now
existing.UpdatedBy = userEmail
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Upsert(ctx, existing); err != nil {
return err
}
return m.ch.Sync(ctx, existing.MetricName, existing.Labels, existing.MatchType.StringValue(), existing.EffectiveFrom.UnixMilli(), false, existing.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(existing)
return &gettable, nil
}
func (m *module) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
if err := m.checkAccess(ctx, orgID); err != nil {
return err
}
rule, err := m.store.GetByID(ctx, orgID, id)
if err != nil {
return err
}
now := time.Now()
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
return m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.DeleteByID(ctx, orgID, id); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
})
}
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
}
lookback := time.Duration(req.LookbackMs) * time.Millisecond
if lookback <= 0 {
lookback = defaultPreviewLookback
}
now := time.Now()
startMs := now.Add(-lookback).UnixMilli()
endMs := now.UnixMilli()
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, startMs, endMs)
if err != nil {
return nil, err
}
// Baseline is what the metric keeps today (its current rule, or raw if none) so the preview reads
// as current -> proposed.
currentReduced := current
if existing, gerr := m.store.Get(ctx, orgID, req.MetricName); gerr == nil {
if _, existingReduced, _, _, eerr := m.estimateVolume(ctx, req.MetricName, existing.MatchType, existing.Labels, startMs, endMs); eerr == nil {
currentReduced = existingReduced
}
}
return &metricreductionruletypes.GettableReductionRulePreview{
IngestedSeries: current,
CurrentRetainedSeries: currentReduced,
RetainedSeries: reduced,
ReductionPercent: reductionPercent,
DroppedLabels: dropped,
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
EffectiveFrom: now.Add(effectiveFromMargin),
}, nil
}
func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
}
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
}, nil
}
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
if reducedSamples >= ingestedSamples || endMs <= startMs {
return 0
}
savedSamples := float64(ingestedSamples - reducedSamples)
monthlySamples := savedSamples * float64(monthDuration.Milliseconds()) / float64(endMs-startMs)
return monthlySamples / 1_000_000 * pricePerMillionSamplesUSD
}
func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuildertypesv5.QueryRangeResponse, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return buildVolumeTimeseries(points), nil
}
func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRangeResponse {
ingested := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
reduced := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
for _, point := range points {
ingested = append(ingested, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Ingested)})
reduced = append(reduced, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Reduced)})
}
return &querybuildertypesv5.QueryRangeResponse{
Type: querybuildertypesv5.RequestTypeTimeSeries,
Data: querybuildertypesv5.QueryData{
Results: []any{
&querybuildertypesv5.TimeSeriesData{
QueryName: "reduction_volume",
Aggregations: []*querybuildertypesv5.AggregationBucket{
{
Series: []*querybuildertypesv5.TimeSeries{
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "ingested"}}, Values: ingested},
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "retained"}}, Values: reduced},
},
},
},
},
},
},
}
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
if err != nil {
return err
}
if lastSeen[metricName] == 0 {
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
}
now := time.Now()
startTs := uint64(now.Add(-defaultPreviewLookback).UnixMilli())
endTs := uint64(now.UnixMilli())
_, types, _, err := m.metadataStore.FetchTemporalityAndTypeMulti(ctx, orgID, startTs, endTs, metricName)
if err != nil {
return err
}
if types[metricName] == metrictypes.ExpHistogramType {
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
"exponential histogram metrics cannot be reduced in v1")
}
return nil
}
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
affected := make([]metricreductionruletypes.AffectedAsset, 0)
droppedSet := make(map[string]struct{}, len(dropped))
for _, label := range dropped {
droppedSet[label] = struct{}{}
}
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
}
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, a := range alerts {
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeAlert,
ID: a.AlertID,
Name: a.AlertName,
})
}
}
return affected
}
func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metricreductionruletypes.GettableReductionRule {
return metricreductionruletypes.GettableReductionRule{
Identifiable: rule.Identifiable,
TimeAuditable: rule.TimeAuditable,
UserAuditable: rule.UserAuditable,
MetricName: rule.MetricName,
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
Active: !rule.EffectiveFrom.After(time.Now()),
}
}
func effectiveRetained(ingested, reduced uint64) uint64 {
if reduced == 0 || reduced > ingested {
return ingested
}
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
}
return rule
}
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
seen := make(map[string]struct{})
var out []string
for _, key := range keys {
if _, ok := droppedSet[key]; !ok {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, key)
}
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {
ruleSet[l] = struct{}{}
}
for _, k := range keys {
if metricreductionruletypes.IsProtectedLabel(k) {
kept = append(kept, k)
continue
}
_, listed := ruleSet[k]
drop := listed
if matchType == metricreductionruletypes.MatchTypeKeep {
drop = !listed
}
if drop {
dropped = append(dropped, k)
} else {
kept = append(kept, k)
}
}
sort.Strings(dropped)
sort.Strings(kept)
return dropped, kept
}
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
dropped, kept := resolveDroppedKept(matchType, labels, keys)
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
if current > 0 && reduced <= current {
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
}
return current, reduced, reductionPercent, dropped, nil
}

View File

@@ -1,145 +0,0 @@
package implmetricreductionrule
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.ReductionRule, int, error) {
column := "metric_name"
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
column = "updated_at"
}
direction := "ASC"
if params.Order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
rules := make([]*metricreductionruletypes.ReductionRule, 0)
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rules).
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Search != "" {
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)
}
if params.Limit > 0 {
query = query.Limit(params.Limit).Offset(params.Offset)
}
total, err := query.ScanAndCount(ctx)
if err != nil {
return nil, 0, err
}
return rules, total, nil
}
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("metric_name = ?", metricName).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
}
return rule, nil
}
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
}
return rule, nil
}
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO NOTHING").
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeAlreadyExists, metricreductionruletypes.ErrCodeMetricReductionRuleAlreadyExists,
"a reduction rule for metric %q already exists", rule.MetricName)
}
return nil
}
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO UPDATE").
Set("match_type = EXCLUDED.match_type").
Set("labels = EXCLUDED.labels").
Set("effective_from = EXCLUDED.effective_from").
Set("updated_at = EXCLUDED.updated_at").
Set("updated_by = EXCLUDED.updated_by").
Exec(ctx)
return err
}
func (s *store) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*metricreductionruletypes.ReductionRule)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
}
return nil
}
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}

View File

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

View File

@@ -152,7 +152,3 @@ func (f *formatter) LowerExpression(expression string) []byte {
sql = append(sql, ')')
return sql
}
func (f *formatter) EscapeLikePattern(value string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
}

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
# Usage: bash frontend/scripts/extract-md-languages.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="$SCRIPT_DIR/../src"
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
| sed 's/^```//' \
| grep -v '^$' \
| sort -u

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Validates that all fenced code block languages used in .md files are registered
# in the syntax highlighter.
# Usage: bash frontend/scripts/validate-md-languages.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
# Get all languages used in .md files
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
# Get all registered languages from syntaxHighlighter.ts
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
missing_languages=()
for lang in $md_languages; do
# Skip ai-* block markers — these are custom AI block types rendered by
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
# not real syntax languages, so they don't need highlighter registration.
if [[ "$lang" == ai-* ]]; then
continue
fi
if ! echo "$registered_languages" | grep -qx "$lang"; then
missing_languages+=("$lang")
fi
done
if [ ${#missing_languages[@]} -gt 0 ]; then
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
for lang in "${missing_languages[@]}"; do
echo " - $lang"
done
echo ""
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
exit 1
fi
echo "All markdown code block languages are registered in syntaxHighlighter.ts"

View File

@@ -3,12 +3,12 @@ import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -37,11 +37,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
activeLicense,
isFetchingActiveLicense,
trialInfo,
featureFlags,
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
@@ -133,14 +133,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <Redirect to={ROUTES.HOME} />;
}
if (
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
@@ -220,6 +212,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}
}
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
if (
currentRoute?.path === ROUTES.GET_STARTED &&
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
) {
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
}
// Main routing logic
if (currentRoute) {
const { isPrivate, key } = currentRoute;

View File

@@ -2,6 +2,7 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
@@ -1262,6 +1263,80 @@ describe('PrivateRoute', () => {
});
});
describe('Get Started Route Redirect', () => {
it('should redirect to GET_STARTED_WITH_CLOUD when on GET_STARTED and ONBOARDING_V3 feature flag is active', async () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
await assertRedirectsTo(ROUTES.GET_STARTED_WITH_CLOUD);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is inactive', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: false,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
});
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
featureFlags: [],
},
});
assertStaysOnRoute(ROUTES.GET_STARTED);
});
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
renderPrivateRoute({
initialRoute: ROUTES.HOME,
appContext: {
isLoggedIn: true,
featureFlags: [
{
name: FeatureKeys.ONBOARDING_V3,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
});
assertStaysOnRoute(ROUTES.HOME);
});
});
describe('Loading States', () => {
it('should not redirect while license is still being fetched', () => {
renderPrivateRoute({
@@ -1421,16 +1496,16 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
it('should allow EDITOR to access /get-started route', () => {
renderPrivateRoute({
initialRoute: ROUTES.GET_STARTED_WITH_CLOUD,
initialRoute: ROUTES.GET_STARTED,
appContext: {
isLoggedIn: true,
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
},
});
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
assertStaysOnRoute(ROUTES.GET_STARTED);
});
});

View File

@@ -57,6 +57,13 @@ export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetail = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
@@ -83,6 +90,14 @@ export const SettingsPage = Loadable(
() => import(/* webpackChunkName: "SettingsPage" */ 'pages/Settings'),
);
export const GettingStarted = Loadable(
() => import(/* webpackChunkName: "GettingStarted" */ 'pages/GettingStarted'),
);
export const Onboarding = Loadable(
() => import(/* webpackChunkName: "Onboarding" */ 'pages/OnboardingPage'),
);
export const OrgOnboarding = Loadable(
() => import(/* webpackChunkName: "OrgOnboarding" */ 'pages/OrgOnboarding'),
);
@@ -322,17 +337,3 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
),
);
export const LLMObservabilityModelPricingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
),
);

View File

@@ -23,8 +23,6 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityPage,
LLMObservabilityModelPricingPage,
LiveLogs,
Login,
Logs,
@@ -35,6 +33,7 @@ import {
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
Onboarding,
OnboardingV2,
OrgOnboarding,
PasswordReset,
@@ -71,6 +70,13 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED,
exact: false,
component: Onboarding,
isPrivate: true,
key: 'GET_STARTED',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
exact: false,
@@ -471,13 +477,6 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
},
{
path: ROUTES.METER,
@@ -514,20 +513,6 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_BASE,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_BASE',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
exact: true,
component: LLMObservabilityModelPricingPage,
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -18,776 +18,32 @@ import type {
} from 'react-query';
import type {
CreateMetricReductionRule201,
DeleteMetricReductionRuleByIDPathParameters,
GetMetricAlerts200,
GetMetricAlertsParams,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsV2200,
GetMetricDashboardsV2Params,
GetMetricHighlights200,
GetMetricHighlightsParams,
GetMetricMetadata200,
GetMetricMetadataParams,
GetMetricReductionRuleByID200,
GetMetricReductionRuleByIDPathParameters,
GetMetricReductionRuleStats200,
GetMetricReductionRuleTimeseries200,
GetMetricsOnboardingStatus200,
GetMetricsStats200,
GetMetricsTreemap200,
InspectMetrics200,
ListMetricReductionRules200,
ListMetricReductionRulesParams,
ListMetrics200,
ListMetricsParams,
MetricreductionruletypesPostableReductionRuleDTO,
MetricreductionruletypesPostableReductionRulePreviewDTO,
MetricreductionruletypesUpdatableReductionRuleDTO,
MetricsexplorertypesInspectMetricsRequestDTO,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
PreviewMetricReductionRule200,
RenderErrorResponseDTO,
UpdateMetricReductionRuleByID200,
UpdateMetricReductionRuleByIDPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns active metric volume-control (label reduction) rules.
* @summary List metric reduction rules
*/
export const listMetricReductionRules = (
params?: ListMetricReductionRulesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListMetricReductionRules200>({
url: `/api/v2/metric_reduction_rules`,
method: 'GET',
params,
signal,
});
};
export const getListMetricReductionRulesQueryKey = (
params?: ListMetricReductionRulesParams,
) => {
return [
`/api/v2/metric_reduction_rules`,
...(params ? [params] : []),
] as const;
};
export const getListMetricReductionRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListMetricReductionRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListMetricReductionRulesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listMetricReductionRules>>
> = ({ signal }) => listMetricReductionRules(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMetricReductionRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listMetricReductionRules>>
>;
export type ListMetricReductionRulesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List metric reduction rules
*/
export function useListMetricReductionRules<
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListMetricReductionRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMetricReductionRulesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List metric reduction rules
*/
export const invalidateListMetricReductionRules = async (
queryClient: QueryClient,
params?: ListMetricReductionRulesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListMetricReductionRulesQueryKey(params) },
options,
);
return queryClient;
};
/**
* Creates a volume-control rule for a metric and returns it with its id; fails if the metric already has a rule.
* @summary Create a metric reduction rule
*/
export const createMetricReductionRule = (
metricreductionruletypesPostableReductionRuleDTO?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateMetricReductionRule201>({
url: `/api/v2/metric_reduction_rules`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesPostableReductionRuleDTO,
signal,
});
};
export const getCreateMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
> => {
const mutationKey = ['createMetricReductionRule'];
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 createMetricReductionRule>>,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> }
> = (props) => {
const { data } = props ?? {};
return createMetricReductionRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof createMetricReductionRule>>
>;
export type CreateMetricReductionRuleMutationBody =
| BodyType<MetricreductionruletypesPostableReductionRuleDTO>
| undefined;
export type CreateMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create a metric reduction rule
*/
export const useCreateMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
TContext
> => {
return useMutation(getCreateMetricReductionRuleMutationOptions(options));
};
/**
* Deletes a volume-control rule by its id.
* @summary Delete a metric reduction rule by id
*/
export const deleteMetricReductionRuleByID = (
{ id }: DeleteMetricReductionRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metric_reduction_rules/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteMetricReductionRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['deleteMetricReductionRuleByID'];
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 deleteMetricReductionRuleByID>>,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteMetricReductionRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMetricReductionRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>
>;
export type DeleteMetricReductionRuleByIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete a metric reduction rule by id
*/
export const useDeleteMetricReductionRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
TError,
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
TContext
> => {
return useMutation(getDeleteMetricReductionRuleByIDMutationOptions(options));
};
/**
* Returns a single volume-control rule by its id.
* @summary Get a metric reduction rule by id
*/
export const getMetricReductionRuleByID = (
{ id }: GetMetricReductionRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricReductionRuleByID200>({
url: `/api/v2/metric_reduction_rules/${id}`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleByIDQueryKey = ({
id,
}: GetMetricReductionRuleByIDPathParameters) => {
return [`/api/v2/metric_reduction_rules/${id}`] as const;
};
export const getGetMetricReductionRuleByIDQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetMetricReductionRuleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleByIDQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
> = ({ signal }) => getMetricReductionRuleByID({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleByIDQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
>;
export type GetMetricReductionRuleByIDQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a metric reduction rule by id
*/
export function useGetMetricReductionRuleByID<
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetMetricReductionRuleByIDPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleByIDQueryOptions(
{ id },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a metric reduction rule by id
*/
export const invalidateGetMetricReductionRuleByID = async (
queryClient: QueryClient,
{ id }: GetMetricReductionRuleByIDPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleByIDQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* Updates the match type and labels of a volume-control rule by its id; the metric name is immutable.
* @summary Update a metric reduction rule by id
*/
export const updateMetricReductionRuleByID = (
{ id }: UpdateMetricReductionRuleByIDPathParameters,
metricreductionruletypesUpdatableReductionRuleDTO?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateMetricReductionRuleByID200>({
url: `/api/v2/metric_reduction_rules/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesUpdatableReductionRuleDTO,
signal,
});
};
export const getUpdateMetricReductionRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
> => {
const mutationKey = ['updateMetricReductionRuleByID'];
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 updateMetricReductionRuleByID>>,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateMetricReductionRuleByID(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMetricReductionRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>
>;
export type UpdateMetricReductionRuleByIDMutationBody =
| BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>
| undefined;
export type UpdateMetricReductionRuleByIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update a metric reduction rule by id
*/
export const useUpdateMetricReductionRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
TError,
{
pathParams: UpdateMetricReductionRuleByIDPathParameters;
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
},
TContext
> => {
return useMutation(getUpdateMetricReductionRuleByIDMutationOptions(options));
};
/**
* Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it.
* @summary Preview a metric reduction rule
*/
export const previewMetricReductionRule = (
metricreductionruletypesPostableReductionRulePreviewDTO?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PreviewMetricReductionRule200>({
url: `/api/v2/metric_reduction_rules/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesPostableReductionRulePreviewDTO,
signal,
});
};
export const getPreviewMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
> => {
const mutationKey = ['previewMetricReductionRule'];
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 previewMetricReductionRule>>,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> }
> = (props) => {
const { data } = props ?? {};
return previewMetricReductionRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type PreviewMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof previewMetricReductionRule>>
>;
export type PreviewMetricReductionRuleMutationBody =
| BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>
| undefined;
export type PreviewMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Preview a metric reduction rule
*/
export const usePreviewMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
> => {
return useMutation(getPreviewMetricReductionRuleMutationOptions(options));
};
/**
* Returns total ingested vs retained series and the estimated monthly savings across all volume-control rules.
* @summary Metric reduction stats
*/
export const getMetricReductionRuleStats = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetMetricReductionRuleStats200>({
url: `/api/v2/metric_reduction_rules/stats`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleStatsQueryKey = () => {
return [`/api/v2/metric_reduction_rules/stats`] as const;
};
export const getGetMetricReductionRuleStatsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleStatsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
> = ({ signal }) => getMetricReductionRuleStats(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleStatsQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
>;
export type GetMetricReductionRuleStatsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Metric reduction stats
*/
export function useGetMetricReductionRuleStats<
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleStatsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Metric reduction stats
*/
export const invalidateGetMetricReductionRuleStats = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleStatsQueryKey() },
options,
);
return queryClient;
};
/**
* Returns ingested vs retained series over time across all volume-control rules (hourly buckets), in the query-range time-series response shape.
* @summary Metric reduction volume over time
*/
export const getMetricReductionRuleTimeseries = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetMetricReductionRuleTimeseries200>({
url: `/api/v2/metric_reduction_rules/timeseries`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleTimeseriesQueryKey = () => {
return [`/api/v2/metric_reduction_rules/timeseries`] as const;
};
export const getGetMetricReductionRuleTimeseriesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleTimeseriesQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
> = ({ signal }) => getMetricReductionRuleTimeseries(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleTimeseriesQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
>;
export type GetMetricReductionRuleTimeseriesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Metric reduction volume over time
*/
export function useGetMetricReductionRuleTimeseries<
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleTimeseriesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Metric reduction volume over time
*/
export const invalidateGetMetricReductionRuleTimeseries = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleTimeseriesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint returns a list of distinct metric names within the specified time range
* @summary List metric names
@@ -1789,100 +1045,3 @@ export const useGetMetricsTreemap = <
> => {
return useMutation(getGetMetricsTreemapMutationOptions(options));
};
/**
* This endpoint returns associated v2 dashboards for a specified metric
* @summary Get metric dashboards (v2)
*/
export const getMetricDashboardsV2 = (
params: GetMetricDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboardsV2200>({
url: `/api/v3/metrics/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsV2QueryKey = (
params?: GetMetricDashboardsV2Params,
) => {
return [`/api/v3/metrics/dashboards`, ...(params ? [params] : [])] as const;
};
export const getGetMetricDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsV2QueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboardsV2>>
> = ({ signal }) => getMetricDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricDashboardsV2>>
>;
export type GetMetricDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get metric dashboards (v2)
*/
export function useGetMetricDashboardsV2<
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params: GetMetricDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get metric dashboards (v2)
*/
export const invalidateGetMetricDashboardsV2 = async (
queryClient: QueryClient,
params: GetMetricDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -2178,15 +2178,15 @@ export interface ErrorsResponseerroradditionalDTO {
/**
* @type string
*/
message: string;
message?: string;
/**
* @type array
*/
suggestions: string[];
suggestions?: string[];
}
export interface ErrorsResponseretryjsonDTO {
delay: TimeDurationDTO;
delay?: TimeDurationDTO;
}
export interface ErrorsJSONDTO {
@@ -2197,7 +2197,7 @@ export interface ErrorsJSONDTO {
/**
* @type array
*/
errors: ErrorsResponseerroradditionalDTO[];
errors?: ErrorsResponseerroradditionalDTO[];
/**
* @type string
*/
@@ -2206,11 +2206,11 @@ export interface ErrorsJSONDTO {
/**
* @type array
*/
suggestions: string[];
suggestions?: string[];
/**
* @type string
*/
type: string;
type?: string;
/**
* @type string
*/
@@ -3266,6 +3266,37 @@ export interface DashboardLinkDTO {
url?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
hidden?: boolean;
/**
* @type string
*/
name?: string;
}
export interface DashboardTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display?: VariableDisplayDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
value?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -3297,9 +3328,6 @@ export interface DashboardtypesPanelFormattingDTO {
unit?: string;
}
export enum DashboardtypesLegendModeDTO {
list = 'list',
}
export enum DashboardtypesLegendPositionDTO {
bottom = 'bottom',
right = 'right',
@@ -3319,7 +3347,6 @@ export interface DashboardtypesLegendDTO {
* @type object,null
*/
customColors?: DashboardtypesLegendDTOCustomColors;
mode?: DashboardtypesLegendModeDTO;
position?: DashboardtypesLegendPositionDTO;
}
@@ -3331,7 +3358,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
/**
* @type string
*/
label?: string;
label: string;
/**
* @type string
*/
@@ -3400,14 +3427,12 @@ export interface Querybuildertypesv5FilterDTO {
expression?: string;
}
export type Querybuildertypesv5FunctionArgDTOValue = number | string;
export interface Querybuildertypesv5FunctionArgDTO {
/**
* @type string
*/
name?: string;
value?: Querybuildertypesv5FunctionArgDTOValue;
value?: unknown;
}
export enum Querybuildertypesv5FunctionNameDTO {
@@ -3922,43 +3947,33 @@ export interface DashboardtypesDashboardDTO {
updatedBy?: string;
}
export interface DashboardtypesDashboardPanelRefDTO {
/**
* @type string
*/
dashboardId: string;
/**
* @type string
*/
dashboardName: string;
/**
* @type string
*/
panelId: string;
/**
* @type string
*/
panelName: string;
}
export enum DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind {
export enum DashboardtypesDatasourcePluginVariantStructDTOKind {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface DashboardtypesSigNozDatasourceSpecDTO {
export type DashboardtypesDatasourcePluginVariantStructDTOSpecAnyOf = {
[key: string]: unknown;
}
};
export interface DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO {
/**
* @nullable
*/
export type DashboardtypesDatasourcePluginVariantStructDTOSpec =
DashboardtypesDatasourcePluginVariantStructDTOSpecAnyOf | null;
export interface DashboardtypesDatasourcePluginVariantStructDTO {
/**
* @enum signoz/Datasource
* @type string
*/
kind: DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind;
spec: DashboardtypesSigNozDatasourceSpecDTO;
kind: DashboardtypesDatasourcePluginVariantStructDTOKind;
/**
* @type object,null
*/
spec: DashboardtypesDatasourcePluginVariantStructDTOSpec;
}
export type DashboardtypesDatasourcePluginDTO =
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO;
DashboardtypesDatasourcePluginVariantStructDTO;
export interface DashboardtypesDatasourceSpecDTO {
/**
@@ -4008,12 +4023,10 @@ export enum DashboardtypesLineStyleDTO {
export interface DashboardtypesSpanGapsDTO {
/**
* @type string
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
*/
fillLessThan?: string;
/**
* @type boolean
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
*/
fillOnlyBelow?: boolean;
}
@@ -4252,21 +4265,26 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
}
export type Querybuildertypesv5BuilderQuerySpecDTO =
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
export enum Querybuildertypesv5QueryEnvelopeBuilderDTOType {
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
spec?: Querybuildertypesv5BuilderQuerySpecDTO;
/**
* @type string
* @enum builder_query
*/
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
export interface Querybuildertypesv5QueryEnvelopeBuilderTraceDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5QueryEnvelopeBuilderLogDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5QueryEnvelopeBuilderMetricDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
@@ -4301,16 +4319,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
order?: Querybuildertypesv5OrderByDTO[];
}
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
builder_formula = 'builder_formula',
}
export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
spec?: Querybuildertypesv5QueryBuilderFormulaDTO;
/**
* @type string
* @enum builder_formula
*/
type: Querybuildertypesv5QueryEnvelopeFormulaDTOType;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
@@ -4371,16 +4382,9 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
stepInterval?: Querybuildertypesv5StepDTO;
}
export enum Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType {
builder_trace_operator = 'builder_trace_operator',
}
export interface Querybuildertypesv5QueryEnvelopeTraceOperatorDTO {
spec?: Querybuildertypesv5QueryBuilderTraceOperatorDTO;
/**
* @type string
* @enum builder_trace_operator
*/
type: Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5PromQueryDTO {
@@ -4407,16 +4411,9 @@ export interface Querybuildertypesv5PromQueryDTO {
step?: Querybuildertypesv5StepDTO;
}
export enum Querybuildertypesv5QueryEnvelopePromQLDTOType {
promql = 'promql',
}
export interface Querybuildertypesv5QueryEnvelopePromQLDTO {
spec?: Querybuildertypesv5PromQueryDTO;
/**
* @type string
* @enum promql
*/
type: Querybuildertypesv5QueryEnvelopePromQLDTOType;
type?: Querybuildertypesv5QueryTypeDTO;
}
export interface Querybuildertypesv5ClickHouseQueryDTO {
@@ -4438,24 +4435,40 @@ export interface Querybuildertypesv5ClickHouseQueryDTO {
query?: string;
}
export enum Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType {
clickhouse_sql = 'clickhouse_sql',
}
export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
spec?: Querybuildertypesv5ClickHouseQueryDTO;
/**
* @type string
* @enum clickhouse_sql
*/
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType;
type?: Querybuildertypesv5QueryTypeDTO;
}
export type Querybuildertypesv5QueryEnvelopeDTO =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
| Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO;
| (Querybuildertypesv5QueryEnvelopeBuilderTraceDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeBuilderLogDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeBuilderMetricDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeFormulaDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeTraceOperatorDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopePromQLDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
})
| (Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO & {
spec?: unknown;
type?: Querybuildertypesv5QueryTypeDTO;
});
/**
* Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.
@@ -4553,9 +4566,9 @@ export interface DashboardtypesPanelSpecDTO {
links?: DashboardLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
* @type array,null
*/
queries: DashboardtypesQueryDTO[];
queries: DashboardtypesQueryDTO[] | null;
}
export interface DashboardtypesPanelDTO {
@@ -4585,7 +4598,9 @@ export type DashboardtypesLayoutDTO =
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind {
ListVariable = 'ListVariable',
}
export type DashboardtypesVariableDefaultValueDTO = string | string[];
export interface VariableDefaultValueDTO {
[key: string]: unknown;
}
export enum DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind {
'signoz/DynamicVariable' = 'signoz/DynamicVariable',
@@ -4643,15 +4658,6 @@ export type DashboardtypesVariablePluginDTO =
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
export enum DashboardtypesListVariableSpecSortDTO {
none = 'none',
'alphabetical-asc' = 'alphabetical-asc',
'alphabetical-desc' = 'alphabetical-desc',
'numerical-asc' = 'numerical-asc',
'numerical-desc' = 'numerical-desc',
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
}
export interface DashboardtypesListVariableSpecDTO {
/**
* @type boolean
@@ -4669,15 +4675,17 @@ export interface DashboardtypesListVariableSpecDTO {
* @type string
*/
customAllValue?: string;
defaultValue?: DashboardtypesVariableDefaultValueDTO;
defaultValue?: VariableDefaultValueDTO;
display: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
name?: string;
plugin?: DashboardtypesVariablePluginDTO;
sort?: DashboardtypesListVariableSpecSortDTO;
/**
* @type string,null
*/
sort?: string | null;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
@@ -4689,38 +4697,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesTextVariableSpecDTO {
/**
* @type boolean
*/
constant?: boolean;
display: DashboardtypesDisplayDTO;
/**
* @type string
* @minLength 1
*/
name: string;
/**
* @type string
*/
value: string;
}
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
spec: DashboardtypesTextVariableSpecDTO;
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -6796,213 +6787,6 @@ export interface LlmpricingruletypesUpdatableLLMPricingRulesDTO {
rules: LlmpricingruletypesUpdatableLLMPricingRuleDTO[] | null;
}
export enum MetricreductionruletypesAssetTypeDTO {
dashboard = 'dashboard',
alert_rule = 'alert_rule',
}
export interface MetricreductionruletypesAffectedWidgetDTO {
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
}
export interface MetricreductionruletypesAffectedAssetDTO {
/**
* @type string
*/
id: string;
/**
* @type array,null
*/
impactedLabels: string[] | null;
/**
* @type string
*/
name: string;
type: MetricreductionruletypesAssetTypeDTO;
widget?: MetricreductionruletypesAffectedWidgetDTO;
}
export enum MetricreductionruletypesMatchTypeDTO {
drop = 'drop',
keep = 'keep',
}
export interface MetricreductionruletypesGettableReductionRuleDTO {
/**
* @type boolean
*/
active: boolean;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
* @format date-time
*/
effectiveFrom: string;
/**
* @type string
*/
id: string;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type array,null
*/
labels: string[] | null;
matchType: MetricreductionruletypesMatchTypeDTO;
/**
* @type string
*/
metricName: string;
/**
* @type number
* @format double
*/
reductionPercent: number;
/**
* @type integer
* @minimum 0
*/
retainedSeries: number;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface MetricreductionruletypesGettableReductionRulePreviewDTO {
/**
* @type array,null
*/
affectedAssets: MetricreductionruletypesAffectedAssetDTO[] | null;
/**
* @type integer
* @minimum 0
*/
currentRetainedSeries: number;
/**
* @type array,null
*/
droppedLabels: string[] | null;
/**
* @type string
* @format date-time
*/
effectiveFrom: string;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type number
* @format double
*/
reductionPercent: number;
/**
* @type integer
* @minimum 0
*/
retainedSeries: number;
}
export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
/**
* @type number
* @format double
*/
estimatedMonthlySavingsUsd: number;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
retainedSeries: number;
}
export interface MetricreductionruletypesGettableReductionRulesDTO {
/**
* @type array,null
*/
rules: MetricreductionruletypesGettableReductionRuleDTO[] | null;
/**
* @type integer
*/
total: number;
}
export enum MetricreductionruletypesOrderDTO {
asc = 'asc',
desc = 'desc',
}
export interface MetricreductionruletypesPostableReductionRuleDTO {
/**
* @type array,null
*/
labels: string[] | null;
matchType: MetricreductionruletypesMatchTypeDTO;
/**
* @type string
*/
metricName: string;
}
export interface MetricreductionruletypesPostableReductionRulePreviewDTO {
/**
* @type array,null
*/
labels: string[] | null;
/**
* @type integer
* @format int64
*/
lookbackMs?: number;
matchType: MetricreductionruletypesMatchTypeDTO;
/**
* @type string
*/
metricName: string;
}
export enum MetricreductionruletypesReductionRuleOrderByDTO {
metric = 'metric',
ingested_volume = 'ingested_volume',
reduced_volume = 'reduced_volume',
reduction = 'reduction',
last_updated = 'last_updated',
}
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
/**
* @type array,null
*/
labels: string[] | null;
matchType: MetricreductionruletypesMatchTypeDTO;
}
export interface MetricsexplorertypesInspectMetricsRequestDTO {
/**
* @type integer
@@ -7021,11 +6805,9 @@ export interface MetricsexplorertypesInspectMetricsRequestDTO {
start: number;
}
export type Querybuildertypesv5LabelDTOValue = string | number | boolean;
export interface Querybuildertypesv5LabelDTO {
key?: TelemetrytypesTelemetryFieldKeyDTO;
value?: Querybuildertypesv5LabelDTOValue;
value?: unknown;
}
export interface Querybuildertypesv5BucketDTO {
@@ -7176,13 +6958,6 @@ export interface MetricsexplorertypesMetricDashboardDTO {
widgetName: string;
}
export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
/**
* @type array,null
*/
dashboards: DashboardtypesDashboardPanelRefDTO[] | null;
}
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
/**
* @type array,null
@@ -7642,20 +7417,9 @@ export enum Querybuildertypesv5VariableTypeDTO {
custom = 'custom',
text = 'text',
}
export type Querybuildertypesv5VariableItemDTOValueOneOfItem =
| string
| number
| boolean;
export type Querybuildertypesv5VariableItemDTOValue =
| string
| number
| boolean
| Querybuildertypesv5VariableItemDTOValueOneOfItem[];
export interface Querybuildertypesv5VariableItemDTO {
type?: Querybuildertypesv5VariableTypeDTO;
value?: Querybuildertypesv5VariableItemDTOValue;
value?: unknown;
}
export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
@@ -7703,13 +7467,6 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface RenderErrorResponseDTO {
error: ErrorsJSONDTO;
/**
@@ -10690,102 +10447,6 @@ export type Livez200 = {
status: string;
};
export type ListMetricReductionRulesParams = {
/**
* @description undefined
*/
orderBy?: MetricreductionruletypesReductionRuleOrderByDTO;
/**
* @description undefined
*/
order?: MetricreductionruletypesOrderDTO;
/**
* @type string
* @description undefined
*/
search?: string;
/**
* @type string
* @description undefined
*/
metricName?: string;
/**
* @type integer
* @description undefined
*/
offset?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
};
export type ListMetricReductionRules200 = {
data: MetricreductionruletypesGettableReductionRulesDTO;
/**
* @type string
*/
status: string;
};
export type CreateMetricReductionRule201 = {
data: MetricreductionruletypesGettableReductionRuleDTO;
/**
* @type string
*/
status: string;
};
export type DeleteMetricReductionRuleByIDPathParameters = {
id: string;
};
export type GetMetricReductionRuleByIDPathParameters = {
id: string;
};
export type GetMetricReductionRuleByID200 = {
data: MetricreductionruletypesGettableReductionRuleDTO;
/**
* @type string
*/
status: string;
};
export type UpdateMetricReductionRuleByIDPathParameters = {
id: string;
};
export type UpdateMetricReductionRuleByID200 = {
data: MetricreductionruletypesGettableReductionRuleDTO;
/**
* @type string
*/
status: string;
};
export type PreviewMetricReductionRule200 = {
data: MetricreductionruletypesGettableReductionRulePreviewDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricReductionRuleStats200 = {
data: MetricreductionruletypesGettableReductionRuleStatsDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricReductionRuleTimeseries200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**
* @type string
*/
status: string;
};
export type ListMetricsParams = {
/**
* @type integer,null
@@ -11464,22 +11125,6 @@ export type GetHosts200 = {
status: string;
};
export type GetMetricDashboardsV2Params = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricDashboardsV2200 = {
data: MetricsexplorertypesMetricDashboardPanelsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetFlamegraphPathParameters = {
traceID: string;
};

View File

@@ -0,0 +1,35 @@
import { ApiV2Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const getTraceV2 = async (
props: GetTraceV2PayloadProps,
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
}
const postData: GetTraceV2PayloadProps = {
...props,
uncollapsedSpans,
};
const response = await axios.post<GetTraceV2SuccessResponse>(
`/traces/waterfall/${props.traceId}`,
omit(postData, 'traceId'),
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
};
export default getTraceV2;

View File

@@ -41,7 +41,6 @@ const getTraceV4 = async (
> & { spans: WireSpan[] | null };
// Derive 'service.name' from resource for convenience — only derived field
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
...span,
'service.name': span.resource?.['service.name'] || '',

View File

@@ -0,0 +1,25 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
function FlamegraphImg(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default FlamegraphImg;

View File

@@ -1,163 +0,0 @@
.inviteMembers {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.header {
display: flex;
gap: var(--spacing-8);
align-items: center;
width: 100%;
}
.headerCellEmail {
flex: 1 1 0;
min-width: 0;
}
.headerCellRole {
flex: 0 0 160px;
width: 160px;
}
.headerCellAction {
flex: 0 0 32px;
width: 32px;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
width: 100%;
}
.row {
display: flex;
gap: var(--spacing-8);
align-items: flex-start;
width: 100%;
}
.cellEmail {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 1 1 0;
min-width: 0;
--input-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-hover-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-focus-background: var(
--invite-members-field-background,
var(--l2-background)
);
--input-disabled-background: var(
--invite-members-field-background,
var(--l2-background)
);
input::placeholder {
color: var(--l3-foreground);
}
}
.cellRole {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 160px;
width: 160px;
:global(.roles-single-select) {
width: 100%;
:global(.ant-select-selector) {
background-color: var(
--invite-members-field-background,
var(--l2-background)
) !important;
}
}
}
.cellAction {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 0 0 32px;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
}
.errorText {
color: var(--danger-background);
}
.addRow {
display: flex;
justify-content: flex-start;
margin-top: var(--spacing-2);
}
.callout {
animation: shake 300ms ease-out;
&[data-type='success'] {
animation: none;
}
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.resultsList {
margin: var(--spacing-2) 0 0 var(--spacing-8);
padding: 0;
li {
margin-bottom: var(--spacing-1);
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -1,221 +0,0 @@
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import RolesSelect from 'components/RolesSelect/RolesSelect';
import styles from './InviteMembers.module.scss';
import { InviteMembersProps } from './types';
import { useInviteMembers } from './useInviteMembers';
function InviteMembers({
className,
initialRowCount = 3,
minRows = 1,
emailPlaceholder = 'e.g. john@signoz.io',
showHeader = true,
showAddButton = true,
onSuccess,
onPartialSuccess,
onAllFailed,
renderFooter,
}: InviteMembersProps): JSX.Element {
const {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
} = useInviteMembers({
initialRowCount,
onSuccess,
onPartialSuccess,
onAllFailed,
});
const canSubmit = !isSubmitting && touchedRows.length > 0;
const canRemoveRow = rows.length > minRows;
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
const hasResults = inviteResults !== null;
const hasFailures = failedResults.length > 0;
const hasSuccesses = successResults.length > 0;
return (
<div className={cx(styles.inviteMembers, className)}>
<div className={styles.table}>
{showHeader && (
<div className={styles.header}>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellEmail}
>
Email address
</Typography.Text>
<Typography.Text
size="base"
weight="semibold"
className={styles.headerCellRole}
>
Role
</Typography.Text>
<div className={styles.headerCellAction} />
</div>
)}
<div className={styles.rows}>
{rows.map((row) => (
<div key={row.id} className={styles.row}>
<div className={styles.cellEmail}>
<Input
type="email"
placeholder={emailPlaceholder}
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
name={`invite-email-${row.id}`}
autoComplete="email"
data-testid={`invite-email-${row.id}`}
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text size="small" className={styles.errorText}>
Invalid email address
</Typography.Text>
)}
</div>
<div className={styles.cellRole}>
<RolesSelect
mode="single"
value={row.roleId || undefined}
onChange={(roleId): void => updateRole(row.id, roleId)}
placeholder="Select role"
allowClear={false}
id={`invite-role-${row.id}`}
/>
</div>
<div className={styles.cellAction}>
{canRemoveRow && (
<Button
variant="ghost"
color="destructive"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
data-testid={`invite-remove-${row.id}`}
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
{showAddButton && (
<div className={styles.addRow}>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
onClick={addRow}
data-testid="invite-add-row"
>
Add another
</Button>
</div>
)}
</div>
{hasValidationErrors && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-validation-error"
>
{getValidationErrorMessage()}
</Callout>
)}
{hasResults && hasFailures && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className={styles.callout}
data-testid="invite-api-error"
>
<div className={styles.results}>
{hasSuccesses && (
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully.
</Typography.Text>
)}
<Typography.Text size="small">
{failedResults.length} invite(s) failed:
</Typography.Text>
<ul className={styles.resultsList}>
{failedResults.map((result) => (
<li key={result.email}>
<Typography.Text size="small">
{result.email}: {result.error}
</Typography.Text>
</li>
))}
</ul>
</div>
</Callout>
)}
{hasResults && !hasFailures && hasSuccesses && (
<Callout
type="success"
size="small"
showIcon
className={styles.callout}
data-testid="invite-success"
>
<Typography.Text size="small">
{successResults.length} invite(s) sent successfully!
</Typography.Text>
</Callout>
)}
{renderFooter?.({
submit,
reset,
canSubmit,
isSubmitting,
touchedCount: touchedRows.length,
})}
</div>
);
}
export default InviteMembers;

View File

@@ -1,240 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Edge Cases', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('reset behavior', () => {
it('clears all rows when reset is called', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={2}
renderFooter={({ reset }): JSX.Element => (
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getByTestId('reset-btn'));
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(resetInputs).toHaveLength(2);
resetInputs.forEach((input) => {
expect(input).toHaveValue('');
});
});
it('clears results on reset', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, reset }): JSX.Element => (
<>
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
<button data-testid="reset-btn" onClick={reset}>
Reset
</button>
</>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('reset-btn'));
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
});
});
describe('results cleared on edit', () => {
it('clears API error when email is edited', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
await user.type(emailInputs[0], 'x');
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
it('clears API error when role is changed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
const viewerElements = screen.getAllByText('Viewer');
await user.click(viewerElements[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await waitFor(() => {
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
});
});
});
describe('empty submission', () => {
it('does not submit when no rows are touched', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
await user.click(screen.getByTestId('submit-btn'));
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe('submitting state', () => {
it('disables submit while submitting', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit, isSubmitting }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
const submitBtn = screen.getByTestId('submit-btn');
await user.click(submitBtn);
await waitFor(() => {
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
});
});
});
describe('whitespace handling', () => {
it('trims email whitespace before submission', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const calls: { email: string }[] = [];
server.use(
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}),
);
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], ' alice@signoz.io ');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0].email).toBe('alice@signoz.io');
});
});
});
});

View File

@@ -1,95 +0,0 @@
import { server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Rendering', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('renders default initial row count of 3', () => {
render(<InviteMembers />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(3);
});
it('renders custom initial row count', () => {
render(<InviteMembers initialRowCount={5} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(emailInputs).toHaveLength(5);
});
it('renders header by default', () => {
render(<InviteMembers />);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<InviteMembers showHeader={false} />);
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
expect(screen.queryByText('Role')).not.toBeInTheDocument();
});
it('renders add button by default', () => {
render(<InviteMembers />);
expect(
screen.getByRole('button', { name: /add another/i }),
).toBeInTheDocument();
});
it('hides add button when showAddButton is false', () => {
render(<InviteMembers showAddButton={false} />);
expect(
screen.queryByRole('button', { name: /add another/i }),
).not.toBeInTheDocument();
});
it('renders custom email placeholder', () => {
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
expect(emailInputs).toHaveLength(3);
});
it('applies custom className', () => {
const { container } = render(<InviteMembers className="custom-class" />);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
it('renders footer via renderFooter prop', () => {
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="custom-footer" disabled={!canSubmit}>
Custom Submit
</button>
)}
/>,
);
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
expect(screen.getByTestId('custom-footer')).toBeDisabled();
});
it('renders role select for each row', () => {
render(<InviteMembers initialRowCount={2} />);
const roleSelects = screen.getAllByText('Select role');
expect(roleSelects).toHaveLength(2);
});
});

View File

@@ -1,95 +0,0 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import { createRolesHandler, createSuccessHandler } from './testUtils';
describe('InviteMembers - Row Management', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
it('adds a row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
});
it('removes a row when trash button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
});
it('respects minRows constraint when removing rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} minRows={2} />);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
await user.click(screen.getByRole('button', { name: /add another/i }));
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
expect(removeButtons).toHaveLength(3);
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('cannot remove rows below minRows=1 default', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={2} />);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
0,
);
});
it('preserves data in other rows when removing one', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembers initialRowCount={3} />);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'first@signoz.io');
await user.type(emailInputs[2], 'third@signoz.io');
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[1]);
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
expect(remainingInputs).toHaveLength(2);
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
});
});

View File

@@ -1,362 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
CREATE_USER_ENDPOINT,
createErrorHandler,
createRolesHandler,
createSuccessHandler,
createTrackingHandler,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Submission', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('API calls', () => {
it('calls createUser API for each touched row', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
});
});
it('calls createUser API for multiple touched rows', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const { handler, calls } = createTrackingHandler();
server.use(handler);
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.type(emailInputs[2], 'charlie@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const adminOptions = await screen.findAllByText('Admin');
await user.click(adminOptions[adminOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(calls).toHaveLength(3);
});
expect(calls[0]).toMatchObject({
email: 'alice@signoz.io',
userRoles: [{ id: 'role-viewer' }],
});
expect(calls[1]).toMatchObject({
email: 'bob@signoz.io',
userRoles: [{ id: 'role-editor' }],
});
expect(calls[2]).toMatchObject({
email: 'charlie@signoz.io',
userRoles: [{ id: 'role-admin' }],
});
});
});
describe('callbacks', () => {
it('calls onSuccess when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onSuccess = jest.fn();
render(
<InviteMembers
onSuccess={onSuccess}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
it('calls onAllFailed when all invites fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onAllFailed = jest.fn();
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(onAllFailed).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
email: VALID_EMAIL,
success: false,
}),
]),
);
});
});
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onPartialSuccess = jest.fn();
const onSuccess = jest.fn();
const onAllFailed = jest.fn();
const apiCalls: string[] = [];
let callCount = 0;
server.use(
createRolesHandler(),
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
apiCalls.push(body.email);
callCount++;
if (callCount === 1) {
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
}
return res(
ctx.status(409),
ctx.json({
error: {
code: 'already_exists',
message: 'User already exists',
},
}),
);
}),
);
render(
<InviteMembers
initialRowCount={2}
onSuccess={onSuccess}
onPartialSuccess={onPartialSuccess}
onAllFailed={onAllFailed}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select role')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(screen.getByTestId('submit-btn'));
await waitFor(() => {
expect(apiCalls).toHaveLength(2);
});
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
expect(onSuccess).not.toHaveBeenCalled();
expect(onAllFailed).not.toHaveBeenCalled();
expect(onPartialSuccess).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
expect.objectContaining({
email: 'bob@signoz.io',
success: false,
error: 'User already exists',
}),
]),
);
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(
screen.getByText('1 invite(s) sent successfully.'),
).toBeInTheDocument();
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
expect(
screen.getByText('bob@signoz.io: User already exists'),
).toBeInTheDocument();
});
});
describe('result display', () => {
it('shows success callout when all invites succeed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-success'),
).resolves.toBeInTheDocument();
});
it('shows error callout with failed emails when API fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(createErrorHandler('already_exists', 'User already exists'));
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByTestId('invite-api-error'),
).resolves.toBeInTheDocument();
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
});
});
describe('footer props', () => {
it('provides correct canSubmit state', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ canSubmit }): JSX.Element => (
<button data-testid="submit-btn" disabled={!canSubmit}>
Submit
</button>
)}
/>,
);
expect(screen.getByTestId('submit-btn')).toBeDisabled();
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
});
it('provides touchedCount', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ touchedCount }): JSX.Element => (
<span data-testid="touched-count">{touchedCount}</span>
)}
/>,
);
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], 'a@b.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
await user.type(emailInputs[1], 'c@d.com');
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
});
});
});

View File

@@ -1,217 +0,0 @@
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import InviteMembers from '../InviteMembers';
import {
createRolesHandler,
createSuccessHandler,
INVALID_EMAIL,
VALID_EMAIL,
} from './testUtils';
describe('InviteMembers - Validation', () => {
beforeEach(() => {
server.use(createRolesHandler(), createSuccessHandler());
});
afterEach(() => {
server.resetHandlers();
});
describe('email validation', () => {
it('shows email validation error when email is invalid and role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
});
it('shows inline error for invalid email field', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Invalid email address'),
).resolves.toBeInTheDocument();
});
it('clears validation error when email is corrected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
await user.clear(emailInputs[0]);
await user.type(emailInputs[0], VALID_EMAIL);
await waitFor(() => {
expect(
screen.queryByText('Please enter valid emails for team members'),
).not.toBeInTheDocument();
});
});
});
describe('role validation', () => {
it('shows role validation error when role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
});
it('clears role validation error when role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await waitFor(() => {
expect(
screen.queryByText('Please select roles for team members'),
).not.toBeInTheDocument();
});
});
});
describe('combined validation', () => {
it('shows combined error when both email and role are invalid', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], INVALID_EMAIL);
await user.click(screen.getByTestId('submit-btn'));
await expect(
screen.findByText(
'Please enter valid emails and select roles for team members',
),
).resolves.toBeInTheDocument();
});
});
describe('touched rows', () => {
it('only validates touched rows (rows with email or role)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<InviteMembers
initialRowCount={3}
renderFooter={({ submit }): JSX.Element => (
<button data-testid="submit-btn" onClick={submit}>
Submit
</button>
)}
/>,
);
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
await user.type(emailInputs[0], VALID_EMAIL);
await user.click(screen.getAllByText('Select role')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(screen.getByTestId('submit-btn'));
expect(
screen.queryByTestId('invite-validation-error'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,54 +0,0 @@
import { RestHandler } from 'msw';
import { rest } from 'mocks-server/server';
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
export const MOCK_ROLES = [
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
];
export const VALID_EMAIL = 'alice@signoz.io';
export const INVALID_EMAIL = 'not-an-email';
export function createSuccessHandler(): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
);
}
export function createErrorHandler(
code: string,
message: string,
status = 409,
): RestHandler {
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(status),
ctx.json({
errors: [{ code, msg: message }],
}),
),
);
}
export function createRolesHandler(): RestHandler {
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
);
}
export function createTrackingHandler(): {
handler: RestHandler;
calls: unknown[];
} {
const calls: unknown[] = [];
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
calls.push(body);
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
});
return { handler, calls };
}

View File

@@ -1,64 +0,0 @@
import { ReactNode } from 'react';
export interface InviteMemberRow {
id: string;
email: string;
roleId: string;
}
export interface InviteResult {
email: string;
success: boolean;
error?: string;
}
export interface FooterRenderProps {
submit: () => Promise<InviteResult[]>;
reset: () => void;
canSubmit: boolean;
isSubmitting: boolean;
touchedCount: number;
}
export interface UseInviteMembersOptions {
initialRowCount?: number;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
}
export interface UseInviteMembersReturn {
rows: InviteMemberRow[];
emailValidity: Record<string, boolean>;
hasInvalidEmails: boolean;
hasInvalidRoles: boolean;
isSubmitting: boolean;
inviteResults: InviteResult[] | null;
addRow: () => void;
removeRow: (id: string) => void;
updateEmail: (id: string, email: string) => void;
updateRole: (id: string, roleId: string | undefined) => void;
reset: () => void;
submit: () => Promise<InviteResult[]>;
touchedRows: InviteMemberRow[];
failedResults: InviteResult[];
successResults: InviteResult[];
canSubmit: boolean;
}
export interface InviteMembersProps {
className?: string;
initialRowCount?: number;
minRows?: number;
emailPlaceholder?: string;
showHeader?: boolean;
showAddButton?: boolean;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
renderFooter?: (props: FooterRenderProps) => ReactNode;
}

View File

@@ -1,245 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { AxiosError } from 'axios';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { createUser } from 'api/generated/services/users';
import { cloneDeep, debounce } from 'lodash-es';
import { EMAIL_REGEX } from 'utils/app';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import {
InviteMemberRow,
InviteResult,
UseInviteMembersOptions,
UseInviteMembersReturn,
} from './types';
const createEmptyRow = (): InviteMemberRow => ({
id: uuid(),
email: '',
roleId: '',
});
const isRowTouched = (row: InviteMemberRow): boolean =>
row.email.trim() !== '' || row.roleId !== '';
export function useInviteMembers(
options: UseInviteMembersOptions = {},
): UseInviteMembersReturn {
const {
initialRowCount = 3,
onSuccess,
onPartialSuccess,
onAllFailed,
} = options;
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
Array.from({ length: initialRowCount }, () => createEmptyRow()),
);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
null,
);
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
const failedResults = useMemo(
() => inviteResults?.filter((r) => !r.success) ?? [],
[inviteResults],
);
const successResults = useMemo(
() => inviteResults?.filter((r) => r.success) ?? [],
[inviteResults],
);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
const validateAllRows = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touched = rows.filter(isRowTouched);
touched.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = row.roleId !== '';
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
updatedEmailValidity[row.id] = emailValid;
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const addRow = useCallback((): void => {
setRows((prev) => [...prev, createEmptyRow()]);
}, []);
const removeRow = useCallback((id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
setEmailValidity((prev) => {
const updated = { ...prev };
delete updated[id];
return updated;
});
}, []);
const updateEmail = useCallback(
(id: string, email: string): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.email = email;
}
return updated;
});
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
if (inviteResults) {
setInviteResults(null);
}
debouncedValidateEmail(email, id);
},
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
);
const updateRole = useCallback(
(id: string, roleId: string | undefined): void => {
setRows((prev) => {
const updated = cloneDeep(prev);
const row = updated.find((r) => r.id === id);
if (row) {
row.roleId = roleId ?? '';
}
return updated;
});
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
if (inviteResults) {
setInviteResults(null);
}
},
[hasInvalidRoles, inviteResults],
);
const reset = useCallback((): void => {
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteResults(null);
}, [initialRowCount]);
const submit = useCallback(async (): Promise<InviteResult[]> => {
if (!validateAllRows()) {
return [];
}
const touched = rows.filter(isRowTouched);
if (touched.length === 0) {
return [];
}
setIsSubmitting(true);
setInviteResults(null);
const results: InviteResult[] = [];
for (const row of touched) {
try {
await createUser({
email: row.email.trim(),
frontendBaseUrl: getBaseUrl(),
userRoles: [{ id: row.roleId }],
});
results.push({ email: row.email, success: true });
} catch (err) {
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
results.push({
email: row.email,
success: false,
error: apiErr?.getErrorMessage() ?? 'Unknown error',
});
}
}
setInviteResults(results);
setIsSubmitting(false);
const failures = results.filter((r) => !r.success);
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
onSuccess?.();
} else if (successes.length > 0) {
onPartialSuccess?.(results);
} else {
onAllFailed?.(results);
}
return results;
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
const canSubmit = useMemo(
() => !isSubmitting && touchedRows.length > 0,
[isSubmitting, touchedRows.length],
);
return {
rows,
emailValidity,
hasInvalidEmails,
hasInvalidRoles,
isSubmitting,
inviteResults,
addRow,
removeRow,
updateEmail,
updateRole,
reset,
submit,
touchedRows,
failedResults,
successResults,
canSubmit,
};
}

View File

@@ -62,13 +62,13 @@ function ErrorTitleAndKey({
switch (parentTitle) {
case 'Consumers':
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
break;
case 'Producers':
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
break;
case 'Kafka':
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
link = `${ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
break;
default:
link = '';

View File

@@ -0,0 +1,106 @@
.span-hover-card {
.ant-popover-inner {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--l1-border);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--muted-foreground);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--danger-background);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
gap: 16px;
}
&__label {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
transparent 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--danger-background);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -0,0 +1,103 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(duration);
const { timezone } = useTimezone();
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
mouseEnterDelay={0.2}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -0,0 +1,292 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TimezoneContextType } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
jest.mock('providers/Timezone', () => ({
__esModule: true,
useTimezone: (): TimezoneContextType => ({
timezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
browserTimezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
formatTimezoneAdjustedTimestampOptional: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),
}));
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
jest.mock('dayjs', () => {
const mockDayjsInstance: any = {};
mockDayjsInstance.format = jest.fn((formatString: string) =>
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
formatString === 'DD MMM YYYY, HH:mm:ss'
? '15 Mar 2024, 14:23:45'
: 'mock-date',
);
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
mockDayjsInstance.utcOffset = jest.fn(() => 0);
const mockDayjs = jest.fn(() => mockDayjsInstance);
Object.assign(mockDayjs, {
extend: jest.fn(),
// Support dayjs.tz.guess()
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
const HOVER_ELEMENT_ID = 'hover-element';
const mockSpan: Span = {
spanId: 'test-span-id',
traceId: 'test-trace-id',
rootSpanId: 'root-span-id',
parentSpanId: 'parent-span-id',
name: 'GET /api/users',
timestamp: 1679748225000000,
durationNano: 150000000,
serviceName: 'user-service',
kind: 1,
hasError: false,
level: 1,
references: [],
tagMap: {},
event: [
{
name: 'event1',
timeUnixNano: 1679748225100000,
attributeMap: {},
isError: false,
},
{
name: 'event2',
timeUnixNano: 1679748225200000,
attributeMap: {},
isError: false,
},
],
rootName: 'root-span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
};
const mockTraceMetadata = {
startTime: 1679748225000000,
endTime: 1679748226000000,
};
describe('SpanHoverCard', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders child element correctly', () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid="child-element">Hover me</div>
</SpanHoverCard>,
);
expect(screen.getByTestId('child-element')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.2 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover over the element
fireEvent.mouseEnter(hoverElement);
// Popover should NOT appear immediately
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(200);
});
// Now popover should appear
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
it('does not show popover if hover is too brief', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover (less than the 0.2s delay)
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(100); // Only 0.1 seconds
});
fireEvent.mouseLeave(hoverElement);
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(400);
});
// Popover should not appear
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
});
it('displays span information in popover content after delay', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check that popover shows span operation name in title
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
// Check duration information
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('150ms')).toBeInTheDocument();
// Check events count
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Check start time label
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {
const spanWithRelativeTime: Span = {
...mockSpan,
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
};
render(
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check relative time display
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
});
it('handles spans with no events correctly', async () => {
const spanWithoutEvents: Span = {
...mockSpan,
event: [],
};
render(
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('verifies mouseEnterDelay prop is set to 0.5', () => {
const { container } = render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
</SpanHoverCard>,
);
// The mouseEnterDelay prop should be set on the Popover component
// This test verifies the implementation includes the delay
const popover = container.querySelector('.ant-popover');
expect(popover).not.toBeInTheDocument(); // Initially not visible
// Hover to trigger delay mechanism
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
fireEvent.mouseEnter(hoverElement);
// Should not appear before delay
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Should appear after delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
});

View File

@@ -7,10 +7,10 @@ export enum FeatureKeys {
GATEWAY = 'gateway',
PREMIUM_SUPPORT = 'premium_support',
ANOMALY_DETECTION = 'anomaly_detection',
ONBOARDING_V3 = 'onboarding_v3',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
}

View File

@@ -11,7 +11,14 @@ const ROUTES = {
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',
GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring',
GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management',
GET_STARTED_INFRASTRUCTURE_MONITORING:
'/get-started/infrastructure-monitoring',
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
USAGE_EXPLORER: '/usage-explorer',
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
@@ -74,7 +81,6 @@ const ROUTES = {
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
METRICS_EXPLORER_BASE: '/metrics-explorer',
@@ -89,8 +95,6 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
} as const;
export default ROUTES;

View File

@@ -48,48 +48,6 @@ describe('getAutoContexts', () => {
]);
});
it('includes the query in alert edit context', () => {
const ruleId = 'rule-edit';
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: {
page: 'alert_edit',
ruleId,
query,
},
},
]);
});
it('includes the query in alert new context (no ruleId)', () => {
const query = { queryType: 'builder', builder: { queryData: [] } };
const compositeQuery = encodeURIComponent(JSON.stringify(query));
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
expect(contexts).toStrictEqual([
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: {
page: 'alert_new',
query,
},
},
]);
});
it('returns triggered alerts context on alert history without ruleId', () => {
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');

View File

@@ -377,63 +377,9 @@
}
.contextPopoverEmpty {
// Fill the entity panel so the state sits centred in the dead space rather
// than clinging to the top-left corner.
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 20px;
text-align: center;
font-size: 12px;
color: var(--l3-foreground);
}
.contextPopoverEmptyIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
// `--empty-accent` is set per category on the root (robin/cherry/forest).
color: var(--empty-accent);
background: color-mix(in srgb, var(--empty-accent), transparent 88%);
border-radius: var(--radius-2);
}
.contextPopoverEmptyTitle {
margin: 0;
max-width: 280px;
color: var(--l2-foreground);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
// Clamp to 2 lines with an ellipsis so a long query can't blow out the
// popover height. The CTA below is a stock DS link button, so the query is
// kept readable here rather than forcing the button to wrap.
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
}
.contextPopoverEmptyCta {
max-width: 100%;
}
.contextPopoverEmptyCtaLabel {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
text-align: center;
padding: 10px 8px;
}
.micBtn {

View File

@@ -42,22 +42,19 @@ import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
import {
Bell,
LayoutDashboard,
Mic,
Plus,
Search,
Send,
ShieldCheck,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
import ContextPickerEmptyState from './ContextPickerEmptyState';
import {
CONTEXT_CATEGORIES,
CONTEXT_CATEGORY_ICONS,
ContextCategory,
} from './contextPicker';
interface ChatInputProps {
onSend: (
@@ -165,6 +162,10 @@ const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
interface SelectedContextItem {
category: ContextCategory;
entityId: string;
@@ -204,6 +205,12 @@ interface ContextEntityItem {
value: string;
}
const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -324,30 +331,6 @@ export default function ChatInput({
[mentionRange, selectedContexts, text],
);
// Empty-state CTA: drop a starter prompt into the composer (never auto-sent)
// and hand the user the caret at the end so they can finish the sentence.
const handleContextPrefill = useCallback(
(prompt: string) => {
const next = capText(prompt);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
setPickerSearchQuery('');
setIsContextPickerOpen(false);
// Defer so React commits the new value before we place the caret.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) {
return;
}
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
});
},
[capText],
);
const focusCategory = useCallback((category: ContextCategory) => {
setActiveContextCategory(category);
setPickerSearchQuery('');
@@ -841,14 +824,10 @@ export default function ChatInput({
// Type-ahead filter against the `@<query>` typed in the textarea. When
// the picker was opened from the "Add Context" button there's no
// mention query, so fall back to the in-popover search input.
const rawMentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end)
const mentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
: '';
const mentionQuery = rawMentionQuery.toLowerCase();
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
// Original-case query for empty-state copy + prefill ("checkout", not the
// lowercased filter key). Mirrors `activeQuery`'s mention-then-search order.
const displayQuery = rawMentionQuery || pickerSearchQuery.trim();
const filteredContextOptions = activeQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(activeQuery),
@@ -1092,11 +1071,9 @@ export default function ChatInput({
Failed to load {activeContextCategory.toLowerCase()}.
</div>
) : filteredContextOptions.length === 0 ? (
<ContextPickerEmptyState
category={activeContextCategory}
query={displayQuery}
onPrefill={handleContextPrefill}
/>
<div className={styles.contextPopoverEmpty}>
No matching entities
</div>
) : (
filteredContextOptions.map((option, index) => {
const isSelected = selectedContexts.some(

View File

@@ -1,68 +0,0 @@
import { Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { CSSProperties } from 'react';
import styles from './ChatInput.module.scss';
import {
CONTEXT_CATEGORY_ICONS,
ContextCategory,
getContextPickerEmptyContent,
} from './contextPicker';
// Per-category accent, mapped to semantic accent tokens (robin is the brand
// primary). Exposed to the SCSS as the `--empty-accent` custom property so the
// icon and CTA share one colour per category.
const CATEGORY_ACCENT: Record<ContextCategory, string> = {
Dashboards: 'var(--accent-primary)',
Alerts: 'var(--accent-cherry)',
Services: 'var(--accent-forest)',
};
interface ContextPickerEmptyStateProps {
category: ContextCategory;
/** The active search query (mention or in-popover search), original case. */
query: string;
/** Drops the starter prompt into the composer (never auto-sends). */
onPrefill: (prompt: string) => void;
}
/**
* Empty state for the @-mention context picker. Distinguishes a brand-new user
* with nothing to pick (onboarding) from a search that matched nothing, and in
* both cases offers a clickable CTA that seeds the composer.
*/
export default function ContextPickerEmptyState({
category,
query,
onPrefill,
}: ContextPickerEmptyStateProps): JSX.Element {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
category,
query,
);
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
return (
<div
className={styles.contextPopoverEmpty}
style={{ '--empty-accent': CATEGORY_ACCENT[category] } as CSSProperties}
>
<span className={styles.contextPopoverEmptyIcon} aria-hidden="true">
<CategoryIcon size={16} />
</span>
<p className={styles.contextPopoverEmptyTitle}>{title}</p>
<Button
type="button"
variant="link"
size="sm"
color="primary"
className={styles.contextPopoverEmptyCta}
onClick={(): void => onPrefill(prefill)}
data-testid={`ai-context-empty-cta-${category}`}
prefix={<Sparkles size={14} />}
>
<span className={styles.contextPopoverEmptyCtaLabel}>{ctaLabel}</span>
</Button>
</div>
);
}

View File

@@ -1,113 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The prefill flow only depends on the context-picker data hooks resolving to
// empty lists (so the empty state renders) — mock them to skip real fetches.
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
isLoading: false,
isError: false,
}),
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
// context providers, so stub them out.
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import { TooltipProvider } from '@signozhq/ui/tooltip';
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
function renderChatInput(): void {
render(
<TooltipProvider>
<ChatInput onSend={jest.fn()} />
</TooltipProvider>,
);
}
function getComposer(): HTMLTextAreaElement {
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
}
describe('ChatInput — empty-state CTA prefill flow', () => {
it('full-replaces existing prose with the query-seeded prompt and closes the picker', async () => {
renderChatInput();
// Pre-existing prose in the composer.
await userEvent.type(getComposer(), 'show me something');
// Open the picker and search for an entity that does not exist.
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.type(
await screen.findByPlaceholderText(/search dashboards/i),
'chk',
);
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Create a dashboard for "chk"');
await userEvent.click(cta);
// Full-replace is intentional: the prefill is a complete sentence, so the
// prior "show me something" prose is discarded rather than producing
// broken grammar (see handleContextPrefill). The query is seeded in.
expect(getComposer().value).toBe('Create a dashboard for chk');
// Picker closed → the empty-state CTA is gone.
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
it('seeds only the prefix (with trailing space) in the onboarding case', async () => {
renderChatInput();
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(getComposer().value).toBe('Create a dashboard for ');
await waitFor(() =>
expect(
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -1,135 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { ContextCategory } from '../contextPicker';
import ContextPickerEmptyState from '../ContextPickerEmptyState';
function renderEmptyState(
category: ContextCategory,
query: string,
onPrefill = jest.fn(),
): { onPrefill: jest.Mock; container: HTMLElement } {
const { container } = render(
<ContextPickerEmptyState
category={category}
query={query}
onPrefill={onPrefill}
/>,
);
return { onPrefill, container };
}
function ctaFor(category: ContextCategory): HTMLElement {
return screen.getByTestId(`ai-context-empty-cta-${category}`);
}
describe('ContextPickerEmptyState', () => {
describe('onboarding (no query)', () => {
it('renders dashboards copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Dashboards', '');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
const cta = ctaFor('Dashboards');
expect(cta).toHaveTextContent('Ask me to create one');
await userEvent.click(cta);
expect(onPrefill).toHaveBeenCalledTimes(1);
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for ');
});
it('renders alerts copy and prefills the prefix only', async () => {
const { onPrefill } = renderEmptyState('Alerts', '');
expect(screen.getByText('No alerts yet.')).toBeInTheDocument();
expect(ctaFor('Alerts')).toHaveTextContent('Ask me to create one');
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for ');
});
it('renders instrumentation-flavoured services copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Services', '');
expect(
screen.getByText('No services reporting data yet.'),
).toBeInTheDocument();
expect(ctaFor('Services')).toHaveTextContent(
'Ask me to help set up instrumentation',
);
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for ',
);
});
it('treats a whitespace-only query as onboarding', () => {
renderEmptyState('Dashboards', ' ');
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(
screen.getByText('No dashboards match "checkout".'),
).toBeInTheDocument();
expect(ctaFor('Dashboards')).toHaveTextContent(
'Create a dashboard for "checkout"',
);
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for checkout');
});
it('seeds the query into alerts copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Alerts', 'checkout');
expect(screen.getByText('No alerts match "checkout".')).toBeInTheDocument();
await userEvent.click(ctaFor('Alerts'));
expect(onPrefill).toHaveBeenCalledWith('Create an alert for checkout');
});
it('uses instrumentation wording for services search misses', async () => {
const { onPrefill } = renderEmptyState('Services', 'checkout');
expect(
screen.getByText('No services match "checkout".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Services'));
expect(onPrefill).toHaveBeenCalledWith(
'Help me set up instrumentation for checkout',
);
});
it('preserves the original casing of the query in copy and prefill', async () => {
const { onPrefill } = renderEmptyState('Dashboards', 'Checkout API');
expect(
screen.getByText('No dashboards match "Checkout API".'),
).toBeInTheDocument();
await userEvent.click(ctaFor('Dashboards'));
expect(onPrefill).toHaveBeenCalledWith(
'Create a dashboard for Checkout API',
);
});
});
describe('per-category accent token', () => {
it.each<[ContextCategory, string]>([
['Dashboards', 'var(--accent-primary)'],
['Alerts', 'var(--accent-cherry)'],
['Services', 'var(--accent-forest)'],
])('maps %s to the semantic accent %s', (category, accent) => {
const { container } = renderEmptyState(category, '');
const root = container.firstChild as HTMLElement;
expect(root.style.getPropertyValue('--empty-accent')).toBe(accent);
});
});
it('does not auto-send: nothing fires until the CTA is clicked', () => {
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
expect(onPrefill).not.toHaveBeenCalled();
});
});

View File

@@ -1,100 +0,0 @@
import {
CONTEXT_CATEGORIES,
getContextPickerEmptyContent,
} from '../contextPicker';
describe('getContextPickerEmptyContent', () => {
describe('onboarding (no query)', () => {
it('returns per-category copy and prefix-only prefill for dashboards', () => {
expect(getContextPickerEmptyContent('Dashboards', '')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('returns per-category copy and prefix-only prefill for alerts', () => {
expect(getContextPickerEmptyContent('Alerts', '')).toStrictEqual({
title: 'No alerts yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create an alert for ',
});
});
it('returns instrumentation-flavoured copy for services', () => {
expect(getContextPickerEmptyContent('Services', '')).toStrictEqual({
title: 'No services reporting data yet.',
ctaLabel: 'Ask me to help set up instrumentation',
prefill: 'Help me set up instrumentation for ',
});
});
it('treats a whitespace-only query as no query', () => {
expect(getContextPickerEmptyContent('Dashboards', ' ')).toStrictEqual({
title: 'No dashboards yet.',
ctaLabel: 'Ask me to create one',
prefill: 'Create a dashboard for ',
});
});
it('leaves the prefill ending in a space so the caret sits after it', () => {
CONTEXT_CATEGORIES.forEach((category) => {
expect(getContextPickerEmptyContent(category, '').prefill).toMatch(/ $/);
});
});
});
describe('search miss (query, no match)', () => {
it('seeds the query into dashboards copy and prefill', () => {
expect(getContextPickerEmptyContent('Dashboards', 'checkout')).toStrictEqual(
{
title: 'No dashboards match "checkout".',
ctaLabel: 'Create a dashboard for "checkout"',
prefill: 'Create a dashboard for checkout',
},
);
});
it('seeds the query into alerts copy and prefill', () => {
expect(getContextPickerEmptyContent('Alerts', 'checkout')).toStrictEqual({
title: 'No alerts match "checkout".',
ctaLabel: 'Create an alert for "checkout"',
prefill: 'Create an alert for checkout',
});
});
it('uses instrumentation wording for services search misses', () => {
expect(getContextPickerEmptyContent('Services', 'checkout')).toStrictEqual({
title: 'No services match "checkout".',
ctaLabel: 'Set up instrumentation for "checkout"',
prefill: 'Help me set up instrumentation for checkout',
});
});
it('preserves the original casing of the query', () => {
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
'Dashboards',
'Checkout API',
);
expect(title).toBe('No dashboards match "Checkout API".');
expect(ctaLabel).toBe('Create a dashboard for "Checkout API"');
expect(prefill).toBe('Create a dashboard for Checkout API');
});
it('trims surrounding whitespace from the query', () => {
expect(
getContextPickerEmptyContent('Dashboards', ' checkout ').prefill,
).toBe('Create a dashboard for checkout');
});
});
it('never emits an em-dash (house style)', () => {
CONTEXT_CATEGORIES.forEach((category) => {
const empty = getContextPickerEmptyContent(category, '');
const miss = getContextPickerEmptyContent(category, 'q');
[empty, miss].forEach(({ title, ctaLabel, prefill }) => {
expect(`${title}${ctaLabel}${prefill}`).not.toContain('—');
});
});
});
});

View File

@@ -1,103 +0,0 @@
import { Bell, LayoutDashboard, ShieldCheck } from '@signozhq/icons';
/** Ordered category tabs shown in the @-mention context picker. */
export const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
export type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
/**
* Icon per category, shared by the picker tablist and the empty state. `satisfies`
* keeps the concrete component types so callers can render `<Icon size={n} />`.
*/
export const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
/**
* Resolved copy + composer prefill for one render of the context picker's empty
* state. The picker is tabbed, so a user only ever views one category at a
* time — each category gets its own onboarding and search-miss copy rather than
* a single combined "nothing to show" line.
*/
export interface ContextPickerEmptyContent {
/** Primary line explaining why the list is empty. */
title: string;
/** Clickable call to action that prefills (never auto-sends) the composer. */
ctaLabel: string;
/**
* Text dropped into the composer when the CTA is clicked. When there's no
* search query this is just the prefix with a trailing space, leaving the
* caret at the end for the user to type the entity name.
*/
prefill: string;
}
interface CategoryCopy {
/** Onboarding line, e.g. "No dashboards yet." */
emptyTitle: string;
/** Onboarding CTA label, e.g. "Ask me to create one". */
emptyCtaLabel: string;
/** Search-miss line, e.g. `No dashboards match "checkout".` */
matchTitle: (query: string) => string;
/** Search-miss CTA label, e.g. `Create a dashboard for "checkout"`. */
matchCtaLabel: (query: string) => string;
/**
* Composer prefill prefix (with trailing space). The prefill is always
* `${prefillPrefix}${query}`, so the search-miss case seeds the query and
* the onboarding case leaves only the prefix.
*/
prefillPrefix: string;
}
// Services get instrumentation-flavoured copy: the assistant can't "create" a
// service, they come from telemetry, so the CTA points at setup instead.
const CONTEXT_PICKER_COPY: Record<ContextCategory, CategoryCopy> = {
Dashboards: {
emptyTitle: 'No dashboards yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No dashboards match "${query}".`,
matchCtaLabel: (query) => `Create a dashboard for "${query}"`,
prefillPrefix: 'Create a dashboard for ',
},
Alerts: {
emptyTitle: 'No alerts yet.',
emptyCtaLabel: 'Ask me to create one',
matchTitle: (query) => `No alerts match "${query}".`,
matchCtaLabel: (query) => `Create an alert for "${query}"`,
prefillPrefix: 'Create an alert for ',
},
Services: {
emptyTitle: 'No services reporting data yet.',
emptyCtaLabel: 'Ask me to help set up instrumentation',
matchTitle: (query) => `No services match "${query}".`,
matchCtaLabel: (query) => `Set up instrumentation for "${query}"`,
prefillPrefix: 'Help me set up instrumentation for ',
},
};
/**
* Build the empty-state copy for a category. The two states are driven solely
* by whether a search query is active: a non-empty query yields the search-miss
* variant, an empty query the onboarding variant.
*/
export function getContextPickerEmptyContent(
category: ContextCategory,
query: string,
): ContextPickerEmptyContent {
const copy = CONTEXT_PICKER_COPY[category];
const trimmed = query.trim();
if (trimmed) {
return {
title: copy.matchTitle(trimmed),
ctaLabel: copy.matchCtaLabel(trimmed),
prefill: `${copy.prefillPrefix}${trimmed}`,
};
}
return {
title: copy.emptyTitle,
ctaLabel: copy.emptyCtaLabel,
prefill: copy.prefillPrefix,
};
}

View File

@@ -124,9 +124,7 @@ export function getAutoContexts(
}
}
// Alert edit — `/alerts/edit?ruleId=…`. The form syncs its query-builder
// state to the URL (`useShareBuilderUrl`), so shared metadata carries the
// alert's query + time range, mirroring the dashboard panel editor.
// Alert edit — `/alerts/edit?ruleId=…`.
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
const ruleId = params.get(QueryParams.ruleId);
if (ruleId) {
@@ -135,21 +133,19 @@ export function getAutoContexts(
source: 'auto',
type: 'alert',
resourceId: ruleId,
metadata: { page: 'alert_edit', ruleId, ...sharedMetadata },
metadata: { page: 'alert_edit', ruleId },
},
];
}
}
// Alert new — `/alerts/new`. No rule id yet (draft), but the query-builder
// state is on the URL, so shared metadata carries the in-progress query.
if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) {
return [
{
source: 'auto',
type: 'alert',
resourceId: null,
metadata: { page: 'alert_new', ...sharedMetadata },
metadata: { page: 'alert_new' },
},
];
}

View File

@@ -26,12 +26,7 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
});
afterEach(() => {
jest.clearAllMocks();

View File

@@ -1,81 +0,0 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -413,8 +413,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isPanelEditorV2 = routeKey === 'DASHBOARD_PANEL_EDITOR';
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
pathname === ROUTES.ONBOARDING ||
pathname === ROUTES.GET_STARTED_WITH_CLOUD ||
pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING ||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard ||
isPanelEditorV2;

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
@@ -27,7 +28,7 @@ interface PieArcProps {
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice) => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
}
/**
@@ -72,7 +73,7 @@ export default function PieArc({
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(): void => onClick?.(slice)}
onClick={(event): void => onClick?.(slice, event)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (

View File

@@ -80,6 +80,7 @@ describe('PieArc', () => {
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
expect(onClick).toHaveBeenCalledWith(SLICE);
// onClick now also receives the DOM event (for drill-down popover positioning).
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
});
});

View File

@@ -1,3 +1,4 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -79,6 +80,10 @@ export interface PieSlice {
label: string;
value: number;
color: string;
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
}
/**
@@ -99,7 +104,7 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice (or its legend entry) is clicked. */
onSliceClick?: (slice: PieSlice) => void;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
'data-testid'?: string;
}

View File

@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
@@ -11,6 +10,7 @@ import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { toast } from '@signozhq/ui/sonner';
import { getChartManagerColumns } from './getChartMangerColumns';
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
@@ -44,7 +44,6 @@ export default function ChartManager({
decimalPrecision = PrecisionOptionsEnum.TWO,
onCancel,
}: ChartManagerProps): JSX.Element {
const { notifications } = useNotifications();
const { legendItemsMap } = useLegendsSync({
config,
subscribeToFocusChange: false,
@@ -136,11 +135,9 @@ export default function ChartManager({
const handleSave = useCallback((): void => {
syncSeriesVisibilityToLocalStorage();
notifications.success({
message: 'The updated graphs & legends are saved',
});
toast.success('The updated graphs & legends are saved');
onCancel?.();
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
return (
<div className="chart-manager-container">

View File

@@ -2,7 +2,7 @@
position: relative;
display: flex;
width: 100%;
height: 100%;
height: auto;
flex-direction: column;
&--legend-right {

View File

@@ -32,7 +32,6 @@ import APIError from 'types/api/error';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
@@ -54,6 +53,11 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const id = channelIdMatch ? channelIdMatch[1] : '';
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -516,7 +520,6 @@ interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -136,7 +136,6 @@ function FormAlertChannels({
<Form.Item>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
@@ -145,7 +144,6 @@ function FormAlertChannels({
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
@@ -153,7 +151,6 @@ function FormAlertChannels({
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}

View File

@@ -0,0 +1,29 @@
.full-screen-header-container {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0;
.brand-logo {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
cursor: pointer;
img {
height: 32px;
width: 32px;
}
.brand-logo-name {
font-family: 'Work Sans', sans-serif;
font-size: 24px;
font-style: normal;
font-weight: 500;
line-height: 18px;
color: var(--l1-foreground);
}
}
}

View File

@@ -0,0 +1,28 @@
import history from 'lib/history';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import './FullScreenHeader.styles.scss';
export default function FullScreenHeader({
overrideRoute,
}: {
overrideRoute?: string;
}): React.ReactElement {
const handleLogoClick = (): void => {
history.push(overrideRoute || '/');
};
return (
<div className="full-screen-header-container">
<div className="brand-logo" onClick={handleLogoClick}>
<img src={signozBrandLogoUrl} alt="SigNoz" />
<div className="brand-logo-name">SigNoz</div>
</div>
</div>
);
}
FullScreenHeader.defaultProps = {
overrideRoute: '/',
};

View File

@@ -1,27 +0,0 @@
.llmObservability {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,18 +0,0 @@
import styles from './LLMObservability.module.scss';
function LLMObservability(): JSX.Element {
return (
<div className={styles.llmObservability} data-testid="llm-observability-page">
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>LLM Observability</h1>
<p className={styles.subtitle}>
Monitor and analyze your LLM usage, costs, and performance
</p>
</div>
</header>
</div>
);
}
export default LLMObservability;

View File

@@ -1,30 +0,0 @@
.llmObservabilityModelPricing {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: var(--spacing-2) 0 0;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}
}

View File

@@ -1,47 +0,0 @@
import { Tabs } from '@signozhq/ui/tabs';
import { Typography } from '@signozhq/ui/typography';
import ModelCostTabPanel from './ModelCostTabPanel';
import styles from './LLMObservabilityModelPricing.module.scss';
function LLMObservabilityModelPricing(): JSX.Element {
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<Typography.Text as="h1" size="large" weight="semibold">
Configuration
</Typography.Text>
<Typography.Text color="muted">
Model pricing and cost estimation settings
</Typography.Text>
</div>
</header>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands, this can become a URL-backed param.
defaultValue="model-costs"
items={[
{
key: 'model-costs',
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
},
]}
/>
</div>
);
}
export default LLMObservabilityModelPricing;

View File

@@ -1,7 +0,0 @@
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
);
const total = data?.data?.total ?? 0;
return (
<>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
/>
<footer>
<Typography.Text color="muted" size="small">
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
</>
);
}
export default ModelCostTabPanel;

View File

@@ -1,8 +0,0 @@
.actionButton {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ModelCostActionsMenu.module.scss';
interface ModelCostActionsMenuProps {
rule: LlmpricingruletypesLLMPricingRuleDTO;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Per-row kebab menu for the model-costs table. Only manage users get actions
// (Edit + Delete); view-only users have nothing to act on, so the cell stays
// empty rather than showing a single-item menu.
function ModelCostActionsMenu({
rule,
canManage,
onEdit,
onDelete,
}: ModelCostActionsMenuProps): JSX.Element | null {
const menuItems = useMemo<MenuItem[]>(
() => [
{
key: 'edit',
label: 'Edit',
onClick: (): void => onEdit(rule),
},
{
key: 'delete',
label: 'Delete',
danger: true,
onClick: (): void => onDelete(rule),
},
],
[onEdit, onDelete, rule],
);
if (!canManage) {
return null;
}
return (
<DropdownMenuSimple menu={{ items: menuItems }} align="end">
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.actionButton}
testId={`model-cost-actions-${rule.id}`}
>
<Ellipsis size={16} />
</Button>
</DropdownMenuSimple>
);
}
export default ModelCostActionsMenu;

View File

@@ -1,20 +0,0 @@
.modelCostsTable {
margin-top: var(--spacing-8);
--tanstack-table-row-height: 48px;
height: calc(100vh - 250px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.modelCostsEmpty {
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,73 +0,0 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SKELETON_ROW_COUNT,
} from '../../../constants';
import styles from './ModelCostsTable.module.scss';
import { getModelCostsColumns } from './TableConfig';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
interface ModelCostsTableProps {
rules: LlmpricingruletypesLLMPricingRuleDTO[];
isLoading: boolean;
total: number;
selectedRuleId: string | null;
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// The table owns its own pagination URL state (page/limit) via enableQueryParams;
// ModelCostsTab reads the same keys to build the list request. Virtual scroll is
// disabled: a plain table renders fine at our page sizes (up to 100 rows) and the
// fixed-height scroll viewport (.modelCostsTable) keeps large pages scrolling
// inside the table.
function ModelCostsTable({
rules,
isLoading,
total,
selectedRuleId,
canManage,
onEdit,
onDelete,
}: ModelCostsTableProps): JSX.Element {
const columns = useMemo(
() => getModelCostsColumns({ canManage, onEdit, onDelete }),
[canManage, onEdit, onDelete],
);
if (!isLoading && rules.length === 0) {
return (
<div className={styles.modelCostsEmpty} data-testid="model-costs-empty">
No model costs yet.
</div>
);
}
return (
<TanStackTable<LlmpricingruletypesLLMPricingRuleDTO>
className={styles.modelCostsTable}
data={rules}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === selectedRuleId}
disableVirtualScroll
testId="model-costs-table"
enableQueryParams={{ page: PAGE_KEY, limit: LIMIT_KEY }}
pagination={{
total,
defaultLimit: PAGE_SIZE,
showTotalCount: true,
totalCountLabel: 'models',
}}
/>
);
}
export default ModelCostsTable;

View File

@@ -1,161 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import { startCase } from 'lodash-es';
import styles from './tableConfig.module.scss';
import ModelCostActionsMenu from '../ModelCostActionsMenu';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
getRelativeLastSeen,
getSourceLabel,
} from '../../../../utils';
interface ColumnsConfig {
canManage: boolean;
onEdit: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
onDelete: (rule: LlmpricingruletypesLLMPricingRuleDTO) => void;
}
// Column definitions for the model-costs TanStackTable. Sorting is intentionally
// off across the board — the list API only accepts offset/limit, so there's no
// server-side ordering to back a sortable header yet.
export function getModelCostsColumns({
canManage,
onEdit,
onDelete,
}: ColumnsConfig): TableColumnDef<LlmpricingruletypesLLMPricingRuleDTO>[] {
return [
{
id: 'model',
header: 'Model',
accessorFn: (row): string => row.modelName ?? '',
// Flexes to absorb spare width alongside Extra buckets so the row fills
// the container instead of leaving a gap on the right.
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.modelCell}>
<Typography.Text
weight="semibold"
truncate={1}
testId={`model-cell-name-${row.id}`}
>
{row.modelName}
</Typography.Text>
<Typography.Text truncate={1}>{getCanonicalId(row)}</Typography.Text>
</div>
),
},
{
id: 'provider',
header: 'Provider',
accessorKey: 'provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider ?? '',
},
{
id: 'input',
header: 'Input / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.input)}
</Typography.Text>
),
},
{
id: 'output',
header: 'Output / 1M',
width: { min: 120 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text>
{formatPricePerMillion(row.pricing?.output)}
</Typography.Text>
),
},
{
id: 'extraBuckets',
header: 'Extra buckets',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const buckets = getExtraBuckets(row);
if (buckets.length === 0) {
return (
<Typography.Text color="muted" as="span">
</Typography.Text>
);
}
return (
<div className={styles.extraBuckets}>
{buckets.map((bucket) => (
<Badge
key={bucket.key}
color="vanilla"
variant="outline"
className={styles.extraBucketsChip}
>
<Typography.Text as="span" size="small">
{startCase(bucket.key)}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(bucket.pricePerMillion)}
</Typography.Text>
</Badge>
))}
</div>
);
},
},
{
id: 'source',
header: 'Source',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={row.isOverride ? 'amber' : 'robin'}
variant="outline"
className={styles.sourceBadge}
data-testid={`source-badge-${row.id}`}
>
{getSourceLabel(row)}
</Badge>
),
},
{
id: 'lastSeen',
header: 'Last seen',
width: { min: 120 },
enableMove: false,
cell: ({ row }): string => getRelativeLastSeen(row),
},
{
id: 'actions',
header: '',
width: { fixed: '56px', ignoreLastColumnFill: true },
pin: 'right',
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element | null => (
<ModelCostActionsMenu
rule={row}
canManage={canManage}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
}

View File

@@ -1,26 +0,0 @@
.modelCell {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.extraBuckets {
display: flex;
// Keep chips on a single line so the row stays at the table's fixed row
// height; the column flexes to 100% so there's room for both.
flex-wrap: nowrap;
gap: var(--spacing-3);
overflow: hidden;
}
.extraBucketsChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
}
.sourceBadge {
margin: 0;
}

View File

@@ -1 +0,0 @@
export { default } from './ModelCostTabPanel';

View File

@@ -1,6 +0,0 @@
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SKELETON_ROW_COUNT = PAGE_SIZE;

View File

@@ -1,4 +0,0 @@
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}

View File

@@ -1,60 +0,0 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
dayjs.extend(relativeTime);
const getRelativeTime = (
timestamp: string | number | Date | null | undefined,
): string => {
const parsed = timestamp != null ? dayjs(timestamp) : null;
return parsed?.isValid() ? parsed.fromNow() : '—';
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
if (value === undefined || value === null) {
return '—';
}
// 2dp is enough for per-1M pricing. we can update this later we models have sub-cent pricing.
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};

View File

@@ -9,7 +9,6 @@ import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { toISOString } from 'utils/app';
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
@@ -27,10 +26,7 @@ function MembersSettings(): JSX.Element {
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done - for search
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
const [isInviteModalOpen, setIsInviteModalOpen] = useQueryState(
'invite',
parseAsBoolean.withDefault(false),
);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const { data: usersData, isLoading, refetch: refetchUsers } = useListUsers();
@@ -205,7 +201,7 @@ function MembersSettings(): JSX.Element {
<Button
variant="solid"
color="primary"
onClick={(): void => void setIsInviteModalOpen(true)}
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
@@ -225,7 +221,7 @@ function MembersSettings(): JSX.Element {
<InviteMembersModal
open={isInviteModalOpen}
onClose={(): void => void setIsInviteModalOpen(null)}
onClose={(): void => setIsInviteModalOpen(false)}
onComplete={handleInviteComplete}
/>

View File

@@ -130,14 +130,4 @@ describe('MembersSettings (integration)', () => {
screen.findAllByPlaceholderText('john@signoz.io'),
).resolves.toHaveLength(3);
});
it('opens InviteMembersModal when invite=true query param is present', async () => {
render(<MembersSettings />, undefined, {
initialRoute: '/settings/members?invite=true',
});
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),
).resolves.toHaveLength(3);
});
});

View File

@@ -21,7 +21,6 @@ import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Highlights from './Highlights';
import Metadata from './Metadata';
import VolumeControlSection from '../VolumeControl/components/VolumeControlSection/VolumeControlSection';
import { MetricDetailsProps } from './types';
import { getMetricDetailsQuery } from './utils';
@@ -191,7 +190,6 @@ function MetricDetails({
isLoadingMetricMetadata={isLoadingMetricMetadata}
refetchMetricMetadata={refetchMetricMetadata}
/>
<VolumeControlSection metricName={metricName} />
<AllAttributes
metricName={metricName}
metricType={metadata?.type}

View File

@@ -77,14 +77,6 @@ jest.mock(
},
);
jest.mock(
'container/MetricsExplorer/VolumeControl/components/VolumeControlSection/VolumeControlSection',
() =>
function MockVolumeControlSection(): JSX.Element {
return <div data-testid="volume-control-section-mock">Volume Control</div>;
},
);
const useGetMetricMetadataMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricMetadata',

View File

@@ -58,7 +58,6 @@
}
.metrics-table-container {
padding-bottom: 48px;
.ant-table {
margin-left: -16px;
margin-right: -16px;

View File

@@ -1,64 +0,0 @@
import {
Querybuildertypesv5QueryRangeResponseDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
Querybuildertypesv5TimeSeriesValueDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
function findSeries(
series: Querybuildertypesv5TimeSeriesDTO[] | null | undefined,
label: string,
): Querybuildertypesv5TimeSeriesDTO | undefined {
return series?.find((entry) =>
(entry.labels ?? []).some((value) => value.value === label),
);
}
function toChartValues(
points: Querybuildertypesv5TimeSeriesValueDTO[],
): [number, string][] {
return points.map((point) => [
Math.floor((point.timestamp ?? 0) / 1000),
String(point.value ?? 0),
]);
}
export function buildVolumeChartPayload(
response?: Querybuildertypesv5QueryRangeResponseDTO,
): SuccessResponse<MetricRangePayloadProps> {
const result = response?.data?.results?.[0] as
| Querybuildertypesv5TimeSeriesDataDTO
| undefined;
const series = result?.aggregations?.[0]?.series;
const ingested = findSeries(series, 'ingested')?.values ?? [];
const retained = findSeries(series, 'retained')?.values ?? [];
return {
statusCode: 200,
message: 'Success',
error: null,
payload: {
data: {
resultType: 'matrix',
result: [
{
queryName: 'ingested',
legend: 'Ingested',
metric: {},
values: toChartValues(ingested),
},
{
queryName: 'retained',
legend: 'Retained',
metric: {},
values: toChartValues(retained),
},
],
newResult: { data: { result: [], resultType: 'matrix' } },
},
},
};
}

View File

@@ -1,23 +0,0 @@
import { Gauge } from '@signozhq/icons';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { Badge } from '@signozhq/ui/badge';
interface VolumeControlBadgeProps {
rule: MetricreductionruletypesGettableReductionRuleDTO;
}
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
return (
<Badge
data-testid="vc-badge-active"
variant="outline"
color={!rule.active ? 'success' : 'warning'}
>
<Gauge size={12} />
{!rule.active ? 'Active' : 'Pending'}
</Badge>
);
}
export default VolumeControlBadge;

View File

@@ -1,17 +0,0 @@
.chart {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
padding: 12px 16px 0 16px;
}
.chartTitle {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.chartBody {
height: 340px;
}

View File

@@ -1,88 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from '@signozhq/ui/typography';
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useTimezone } from 'providers/Timezone';
import { useMemo, useRef } from 'react';
import { buildVolumeChartPayload } from '../../chartUtils';
import styles from './VolumeControlChart.module.scss';
const COLOR_MAPPING: Record<string, string> = {
Ingested: Color.BG_ROBIN_500,
Retained: Color.BG_FOREST_500,
};
interface VolumeControlChartProps {
enabled: boolean;
}
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const payload = useMemo(
() => buildVolumeChartPayload(data?.data).payload,
[data],
);
const chartData = useMemo(() => getUPlotChartData(payload), [payload]);
const config = useMemo(() => {
const timestamps = (chartData[0] as number[]) ?? [];
const builder = buildBaseConfig({
id: 'metric-volume-control',
isDarkMode,
apiResponse: payload,
timezone,
panelType: PANEL_TYPES.BAR,
yAxisUnit: 'short',
onDragSelect: (): void => {},
minTimeScale: timestamps[0],
maxTimeScale: timestamps[timestamps.length - 1],
});
(payload.data.result ?? []).forEach((series) => {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label: series.legend ?? series.queryName,
colorMapping: COLOR_MAPPING,
isDarkMode,
});
});
return builder;
}, [payload, chartData, isDarkMode, timezone]);
return (
<div className={styles.chart} data-testid="volume-control-chart">
<Typography.Text className={styles.chartTitle} size={'small'}>
Series volume over time · ingested vs retained
</Typography.Text>
<div className={styles.chartBody} ref={graphRef}>
{dimensions.width > 0 && (
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
yAxisUnit="short"
timezone={timezone}
legendConfig={{ position: LegendPosition.BOTTOM }}
/>
)}
</div>
</div>
);
}
export default VolumeControlChart;

View File

@@ -1,27 +0,0 @@
.impactPanel {
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-300, #16181d);
padding: 14px;
}
.meterGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.meter {
display: flex;
flex-direction: column;
gap: 5px;
}
.meterLabel {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meterValue {
font-family: 'Geist Mono', monospace;
}

View File

@@ -1,81 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
import { formatCompact } from '../../../configUtils';
import { RuleMode } from '../../../types';
import styles from './ImpactPanel.module.scss';
interface ImpactPanelProps {
mode: RuleMode;
preview?: MetricreductionruletypesGettableReductionRulePreviewDTO;
isLoading: boolean;
}
function ImpactPanel({
mode,
preview,
isLoading,
}: ImpactPanelProps): JSX.Element {
if (mode === 'all') {
return (
<div className={styles.impactPanel} data-testid="volume-control-impact">
<Typography.Text size="small" color="muted">
All attributes remain queryable, no reduction.
</Typography.Text>
</div>
);
}
const current = preview?.currentRetainedSeries ?? 0;
const proposed = preview?.retainedSeries ?? 0;
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
const reductionLabel = `${deltaPct >= 0 ? '' : '+'}${Math.round(
Math.abs(deltaPct),
)}%`;
return (
<div className={styles.impactPanel} data-testid="volume-control-impact">
{isLoading && <Spin size="small" />}
{!isLoading && preview && (
<div className={styles.meterGrid}>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current series
</Typography.Text>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(current)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Proposed series
</Typography.Text>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(proposed)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Reduction
</Typography.Text>
<Typography.Text
size="2xl"
color={deltaPct >= 0 ? 'success' : undefined}
className={styles.meterValue}
>
{reductionLabel}
</Typography.Text>
</div>
</div>
)}
{!isLoading && !preview && (
<Typography.Text size="small" color="muted">
Select attributes to preview the impact.
</Typography.Text>
)}
</div>
);
}
export default ImpactPanel;

View File

@@ -1,10 +0,0 @@
.fieldGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.attributeSelect {
width: 100%;
margin-top: 4px;
}

View File

@@ -1,54 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import { popupContainer } from 'utils/selectPopupContainer';
import { RuleMode } from '../../../types';
import styles from './LabelSelector.module.scss';
interface LabelSelectorProps {
mode: RuleMode;
options: string[];
value: string[];
onChange: (labels: string[]) => void;
loading?: boolean;
}
function LabelSelector({
mode,
options,
value,
onChange,
loading,
}: LabelSelectorProps): JSX.Element {
const helpText =
mode === 'include'
? 'Only the selected attributes will remain queryable.'
: 'The selected attributes will be aggregated away; all others stay queryable.';
return (
<div
className={styles.fieldGroup}
data-testid="volume-control-label-selector"
>
<Typography.Text size="small" weight="semibold">
Attributes
</Typography.Text>
<Typography.Text size="sm" color="muted">
{helpText}
</Typography.Text>
<Select
mode="multiple"
className={styles.attributeSelect}
placeholder="Select attributes"
value={value}
onChange={onChange}
loading={loading}
options={options.map((key) => ({ label: key, value: key }))}
getPopupContainer={popupContainer}
data-testid="volume-control-label-select"
/>
</div>
);
}
export default LabelSelector;

View File

@@ -1,29 +0,0 @@
.modeOptions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.modeOption {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-300, #16181d);
padding: 12px;
cursor: pointer;
transition:
border-color 0.12s ease,
background 0.12s ease;
&:hover {
border-color: var(--bg-slate-200, #2c3140);
}
}
.modeOptionSelected {
border-color: var(--bg-robin-500, #4e74f8);
background: var(--callout-primary-background);
}

View File

@@ -1,64 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { RuleMode } from '../../../types';
import styles from './ModeSelector.module.scss';
interface ModeOption {
mode: RuleMode;
title: string;
description: string;
}
const MODE_OPTIONS: ModeOption[] = [
{
mode: 'all',
title: 'Allow all attributes',
description: 'All attributes stay queryable. Removes any existing rule.',
},
{
mode: 'include',
title: 'Include attributes',
description: 'Allowlist: only the selected attributes stay queryable.',
},
{
mode: 'exclude',
title: 'Exclude attributes',
description: 'Blocklist: the selected attributes are aggregated away.',
},
];
interface ModeSelectorProps {
mode: RuleMode;
onChange: (mode: RuleMode) => void;
}
function ModeSelector({ mode, onChange }: ModeSelectorProps): JSX.Element {
return (
<div
className={styles.modeOptions}
data-testid="volume-control-mode-selector"
>
{MODE_OPTIONS.map((option) => (
<button
type="button"
key={option.mode}
className={cx(styles.modeOption, {
[styles.modeOptionSelected]: mode === option.mode,
})}
onClick={(): void => onChange(option.mode)}
data-testid={`volume-control-mode-${option.mode}`}
>
<Typography.Text size="small" weight="semibold">
{option.title}
</Typography.Text>
<Typography.Text size="sm" color="muted">
{option.description}
</Typography.Text>
</button>
))}
</div>
);
}
export default ModeSelector;

View File

@@ -1,20 +0,0 @@
.warning {
display: flex;
gap: 10px;
padding: 11px 13px;
border-radius: 6px;
background: var(--callout-warning-background);
border: 1px solid var(--callout-warning-border);
color: var(--callout-warning-icon);
}
.warningBody {
display: flex;
flex-direction: column;
gap: 4px;
}
.assetList {
margin: 2px 0 0;
padding-left: 16px;
}

View File

@@ -1,97 +0,0 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import {
MetricreductionruletypesAffectedAssetDTO,
MetricreductionruletypesAssetTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import styles from './RelatedAssetsWarning.module.scss';
const AssetType = MetricreductionruletypesAssetTypeDTO;
function assetHref(
asset: MetricreductionruletypesAffectedAssetDTO,
): string | undefined {
if (!asset.id) {
return undefined;
}
if (asset.type === AssetType.dashboard) {
const base = ROUTES.DASHBOARD.replace(':dashboardId', asset.id);
return asset.widget?.id
? `${base}?${QueryParams.expandedWidgetId}=${asset.widget.id}`
: base;
}
if (asset.type === AssetType.alert_rule) {
return `${ROUTES.EDIT_ALERTS}?ruleId=${asset.id}`;
}
return undefined;
}
interface RelatedAssetsWarningProps {
affectedAssets?: MetricreductionruletypesAffectedAssetDTO[] | null;
}
function RelatedAssetsWarning({
affectedAssets,
}: RelatedAssetsWarningProps): JSX.Element | null {
const impacted = (affectedAssets ?? []).filter(
(asset) =>
asset.type === AssetType.alert_rule ||
(asset.impactedLabels ?? []).length > 0,
);
if (impacted.length === 0) {
return null;
}
const impactedLabels = Array.from(
new Set(impacted.flatMap((asset) => asset.impactedLabels ?? [])),
);
return (
<div className={styles.warning} data-testid="volume-control-warning">
<Info size={14} />
<div className={styles.warningBody}>
<Typography.Text as="div" size="small" weight="semibold" color="warning">
This rule affects {impacted.length} related asset
{impacted.length > 1 ? 's' : ''}.
</Typography.Text>
{impactedLabels.length > 0 && (
<Typography.Text as="div" size="sm" color="muted">
{impactedLabels.join(', ')} will no longer be queryable; affected panels
fall back to aggregated data once the rule applies.
</Typography.Text>
)}
<ul className={styles.assetList}>
{impacted.map((asset) => {
const href = assetHref(asset);
const label = `${asset.name}${
asset.widget ? ` · ${asset.widget.name}` : ''
}`;
return (
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
{href ? (
<Typography.Link
size="sm"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{label}
</Typography.Link>
) : (
<Typography.Text size="sm" color="muted">
{label}
</Typography.Text>
)}
</li>
);
})}
</ul>
</div>
</div>
);
}
export default RelatedAssetsWarning;

View File

@@ -1,30 +0,0 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 2px;
}
.adminRow {
display: flex;
align-items: center;
gap: 10px;
}
.adminOnlyTag {
border: 1px solid var(--bg-amber-500, #ffcc56);
border-radius: 99px;
padding: 1px 8px;
letter-spacing: 0.03em;
}
.footer {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.footerSpacer {
flex: 1;
}

View File

@@ -1,123 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useVolumeControlConfig } from '../../hooks/useVolumeControlConfig';
import ImpactPanel from './ImpactPanel/ImpactPanel';
import LabelSelector from './LabelSelector/LabelSelector';
import ModeSelector from './ModeSelector/ModeSelector';
import RelatedAssetsWarning from './RelatedAssetsWarning/RelatedAssetsWarning';
import styles from './VolumeControlConfigDrawer.module.scss';
interface VolumeControlConfigDrawerProps {
metricName: string;
existingRule: MetricreductionruletypesGettableReductionRuleDTO | null;
open: boolean;
onClose: () => void;
}
function VolumeControlConfigDrawer({
metricName,
existingRule,
open,
onClose,
}: VolumeControlConfigDrawerProps): JSX.Element {
const {
mode,
setMode,
labels,
setLabels,
attributeKeys,
isLoadingAttributes,
preview,
isPreviewLoading,
save,
remove,
isSaving,
isRemoving,
hasExistingRule,
isSaveDisabled,
} = useVolumeControlConfig({ metricName, existingRule, open, onClose });
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
data-testid="volume-control-cancel"
>
Cancel
</Button>
<div className={styles.footerSpacer} />
{hasExistingRule && (
<Button
variant="ghost"
color="destructive"
onClick={remove}
loading={isRemoving}
data-testid="volume-control-remove"
>
Remove rule
</Button>
)}
<Button
variant="solid"
color="primary"
onClick={save}
disabled={isSaveDisabled}
loading={isSaving}
data-testid="volume-control-save"
>
Save rule
</Button>
</div>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(next: boolean): void => {
if (!next) {
onClose();
}
}}
title={`Manage attributes · ${metricName}`}
direction="right"
showCloseButton
width="wide"
footer={footer}
showOverlay={false}
className="volume-control-config-drawer"
style={{ zIndex: 1100 }}
>
<div className={styles.body} data-testid="volume-control-config-drawer">
<div className={styles.adminRow}>
<Typography.Text
size="xs"
weight="semibold"
color="warning"
className={styles.adminOnlyTag}
>
Admin only
</Typography.Text>
</div>
<ModeSelector mode={mode} onChange={setMode} />
{mode !== 'all' && (
<LabelSelector
mode={mode}
options={attributeKeys}
value={labels}
onChange={setLabels}
loading={isLoadingAttributes}
/>
)}
<ImpactPanel mode={mode} preview={preview} isLoading={isPreviewLoading} />
<RelatedAssetsWarning affectedAssets={preview?.affectedAssets} />
</div>
</DrawerWrapper>
);
}
export default VolumeControlConfigDrawer;

View File

@@ -1,12 +0,0 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: flex-start;
border: 1px dashed var(--bg-slate-300, #242834);
border-radius: 6px;
padding: 14px;
}
.setupButton {
margin-top: 12px;
}

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