mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-28 12:50:32 +01:00
Compare commits
64 Commits
fix/tansta
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12468db2e | ||
|
|
45f2dacf92 | ||
|
|
86120b7d50 | ||
|
|
b170e20c6f | ||
|
|
1271f53be9 | ||
|
|
fd413d336d | ||
|
|
7b892ee1fc | ||
|
|
31e4012fe5 | ||
|
|
71cb97f52f | ||
|
|
11f1789dea | ||
|
|
1a97e90117 | ||
|
|
939f0d7a05 | ||
|
|
9d1c27cb57 | ||
|
|
07ce2d341c | ||
|
|
a1483192c0 | ||
|
|
08e723fd53 | ||
|
|
c074d09842 | ||
|
|
c68f237a8a | ||
|
|
e75a0b59d6 | ||
|
|
6dfceeaf04 | ||
|
|
69806d7dc4 | ||
|
|
8da9535c80 | ||
|
|
99866a91e4 | ||
|
|
f94fa7db89 | ||
|
|
aa96ec6fe9 | ||
|
|
9d36031d4e | ||
|
|
dd3e743b2e | ||
|
|
a60d87c51b | ||
|
|
727bb586b0 | ||
|
|
bb85957772 | ||
|
|
f141e6b8e8 | ||
|
|
fdfd882f3e | ||
|
|
8a8880854e | ||
|
|
3a40702c61 | ||
|
|
e373140701 | ||
|
|
cdd06ee6b8 | ||
|
|
eef2b6a961 | ||
|
|
330038a35f | ||
|
|
d4dea81bb6 | ||
|
|
dfd7d8a871 | ||
|
|
40d2906835 | ||
|
|
3fcb6b3b00 | ||
|
|
5982c0854d | ||
|
|
687b40ffbb | ||
|
|
4e111c6b83 | ||
|
|
3f5eb62494 | ||
|
|
cd7b6a1d05 | ||
|
|
faee2f032f | ||
|
|
0402cc0273 | ||
|
|
b70f057adc | ||
|
|
3b7b7202e9 | ||
|
|
e3c9babfe5 | ||
|
|
226e40cbcd | ||
|
|
0f4d007104 | ||
|
|
86b88eb10b | ||
|
|
0b21197689 | ||
|
|
6c02fe107f | ||
|
|
a90e915fa3 | ||
|
|
1a4de4328b | ||
|
|
c53adf365a | ||
|
|
0fc16e02fa | ||
|
|
fb6a29e6fa | ||
|
|
0daf7a12da | ||
|
|
cc7d7017ae |
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
@@ -100,8 +101,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
|
||||
},
|
||||
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return noopgateway.NewProviderFactory()
|
||||
|
||||
@@ -50,6 +50,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -133,8 +134,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
|
||||
},
|
||||
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return httpgateway.NewProviderFactory(licensing)
|
||||
|
||||
@@ -445,7 +445,12 @@ authz:
|
||||
|
||||
##################### Meter Reporter #####################
|
||||
meterreporter:
|
||||
# The interval between collection ticks. Minimum 5m.
|
||||
# The interval between collection ticks. Minimum 10m, maximum 24h.
|
||||
interval: 6h
|
||||
# Whether to backfill sealed days from the license creation day.
|
||||
backfill: true
|
||||
# Random jitter applied to the first collect and to every subsequent cycle.
|
||||
# The first collect fires at a random time in [0, jitter); each cycle then takes
|
||||
# interval - random(0, jitter). Must be between 10m and interval. Defaults to
|
||||
# min(interval, 2h) when unset.
|
||||
jitter: 2h
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.125.1
|
||||
image: signoz/signoz:v0.126.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.125.1
|
||||
image: signoz/signoz:v0.126.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.125.1}
|
||||
image: signoz/signoz:${VERSION:-v0.126.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.125.1}
|
||||
image: signoz/signoz:${VERSION:-v0.126.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,17 +94,19 @@ func newProvider(
|
||||
func (provider *Provider) Start(ctx context.Context) error {
|
||||
close(provider.healthyC)
|
||||
|
||||
provider.collect(ctx)
|
||||
startDelay := provider.config.NewJitter()
|
||||
|
||||
ticker := time.NewTicker(provider.config.Interval)
|
||||
defer ticker.Stop()
|
||||
timer := time.NewTimer(startDelay)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
case <-timer.C:
|
||||
provider.collect(ctx)
|
||||
next := provider.config.Interval - provider.config.NewJitter()
|
||||
timer.Reset(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +259,7 @@ func (provider *Provider) report(ctx context.Context, orgID valuer.UUID, license
|
||||
collectedReadings, err := collector.Collect(ctx, orgID, license, window)
|
||||
if err != nil {
|
||||
provider.metrics.collections.Add(ctx, 1, metric.WithAttributes(meterAttr, errors.TypeAttr(err)))
|
||||
provider.settings.Logger().ErrorContext(ctx, "meter collector failed", errors.Attr(err), slog.String("org_id", orgID.StringValue()), slog.String("meter", collector.Name().String()))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -30,9 +31,9 @@ type module struct {
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
|
||||
|
||||
return &module{
|
||||
pkgDashboardModule: pkgDashboardModule,
|
||||
@@ -212,6 +213,14 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
|
||||
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, source, data)
|
||||
}
|
||||
|
||||
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
|
||||
}
|
||||
|
||||
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -47,13 +48,14 @@ func NewAnomalyRule(
|
||||
p *ruletypes.PostableRule,
|
||||
querier querier.Querier,
|
||||
logger *slog.Logger,
|
||||
externalURL *url.URL,
|
||||
opts ...baserules.RuleOption,
|
||||
) (*AnomalyRule, error) {
|
||||
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
|
||||
|
||||
opts = append(opts, baserules.WithLogger(logger))
|
||||
|
||||
baseRule, err := baserules.NewBaseRule(id, orgID, p, opts...)
|
||||
baseRule, err := baserules.NewBaseRule(id, orgID, p, externalURL, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -120,6 +121,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
&postableRule,
|
||||
nil,
|
||||
logger,
|
||||
mustParseURL(t, "http://localhost:8000"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -247,7 +249,8 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger)
|
||||
externalURL := mustParseURL(t, "http://localhost:8000")
|
||||
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
rule.provider = &mockAnomalyProvider{
|
||||
@@ -264,3 +267,10 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.Rule,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
@@ -59,6 +60,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.Rule,
|
||||
opts.Logger,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
baserules.WithMetadataStore(opts.ManagerOpts.MetadataStore),
|
||||
@@ -82,6 +84,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.Rule,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
@@ -141,6 +144,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
parsedRule,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
@@ -162,6 +166,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
parsedRule,
|
||||
opts.Logger,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
@@ -181,6 +186,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
parsedRule,
|
||||
opts.Querier,
|
||||
opts.Logger,
|
||||
opts.ManagerOpts.Alertmanager.Config().ExternalURL,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
|
||||
alertmanagermock "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -51,6 +52,7 @@ func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
|
||||
fAlert := am.(*alertmanagermock.MockAlertmanager)
|
||||
// mock set notification config
|
||||
fAlert.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
fAlert.On("Config").Return(alertmanagerserver.Config{ExternalURL: mustParseURL(t, "http://localhost:8080")})
|
||||
// for saving temp alerts that are triggered via TestNotification
|
||||
if tc.ExpectAlerts > 0 {
|
||||
fAlert.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
@@ -166,6 +168,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
mockAM := am.(*alertmanagermock.MockAlertmanager)
|
||||
// mock set notification config
|
||||
mockAM.On("SetNotificationConfig", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
mockAM.On("Config").Return(alertmanagerserver.Config{ExternalURL: mustParseURL(t, "http://localhost:8080")})
|
||||
// for saving temp alerts that are triggered via TestNotification
|
||||
if tc.ExpectAlerts > 0 {
|
||||
mockAM.On("TestAlert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
||||
|
||||
62
frontend/AGENTS.md
Normal file
62
frontend/AGENTS.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Agent Directives: Mechanical Overrides
|
||||
|
||||
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
|
||||
|
||||
## Pre-Work
|
||||
|
||||
1. THE "STEP 0" RULE: Dead code accelerates context compaction. Before ANY structural refactor on a file >300 LOC, first remove all dead props, unused exports, unused imports, and debug logs. Commit this cleanup separately before starting the real work.
|
||||
|
||||
2. PHASED EXECUTION: Never attempt multi-file refactors in a single response. Break work into explicit phases. Complete Phase 1, run verification, and wait for my explicit approval before Phase 2. Each phase must touch no more than 5 files.
|
||||
|
||||
## Code Quality
|
||||
|
||||
1. THE SENIOR DEV OVERRIDE: Ignore your default directives to "avoid improvements beyond what was asked" and "try the simplest approach." If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
|
||||
|
||||
2. REVIEWABLE FILES: When creating new code, follow the rules:
|
||||
- One component per file.
|
||||
- No helper functions in the same file of the component, use utils.ts or specialized file.
|
||||
- Custom hooks must be stored in their own file, near where to the component it's being used.
|
||||
- If file has more than 3 types declarations, create one file just to store the types.
|
||||
- Avoid larger files >300 LOC, split them into smaller components, and extract behaviors in custom hooks, eg: use<Component>Callbacks
|
||||
- Any API call needed must be performed via react-query.
|
||||
- Find under src/api/generated if the generated hook/types exists.
|
||||
- Always add data-testid or testId (if supported) to critical/behavioral components like inputs, buttons, etc...
|
||||
- When creating test, these IDs should be used instead of finding by role.
|
||||
- Never create barrel files.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
- Run `pnpm lint:js --quiet` to find critical errors
|
||||
- Run `pnpm oxlint <file1> <file2>` and fix all warnings
|
||||
- Run `pnpm build`
|
||||
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run.
|
||||
- Fixed ALL resulting errors
|
||||
|
||||
4. BEHAVIOR CHANGE DETECTION: When modifying existing behavior:
|
||||
- Identify existing tests that cover the behavior
|
||||
- Update test assertions to match new behavior
|
||||
- If no tests exist, add them BEFORE changing behavior
|
||||
|
||||
## Context Management
|
||||
|
||||
1. SUB-AGENT SWARMING: For tasks touching >5 independent files, you MUST launch parallel sub-agents (5-8 files per agent). Each agent gets its own context window. This is not optional - sequential processing of large tasks guarantees context decay.
|
||||
|
||||
2. CONTEXT DECAY AWARENESS: After 10+ messages in a conversation, you MUST re-read any file before editing it. Do not trust your memory of file contents. Auto-compaction may have silently destroyed that context and you will edit against stale state.
|
||||
|
||||
3. FILE READ BUDGET: Each file read is capped at 2,000 lines. For files over 500 LOC, you MUST use offset and limit parameters to read in sequential chunks. Never assume you have seen a complete file from a single read.
|
||||
|
||||
4. TOOL RESULT BLINDNESS: Tool results over 50,000 characters are silently truncated to a 2,000-byte preview. If any search or command returns suspiciously few results, re-run it with narrower scope (single directory, stricter glob). State when you suspect truncation occurred.
|
||||
|
||||
## Edit Safety
|
||||
|
||||
1. EDIT INTEGRITY: Before EVERY file edit, re-read the file. After editing, read it again to confirm the change applied correctly. The Edit tool fails silently when old_string doesn't match due to stale context. Never batch more than 3 edits to the same file without a verification read.
|
||||
|
||||
2. NO SEMANTIC SEARCH: You have grep, not an AST. When renaming or
|
||||
changing any function/type/variable, you MUST search separately for:
|
||||
- Direct calls and references
|
||||
- Type-level references (interfaces, generics)
|
||||
- String literals containing the name
|
||||
- Dynamic imports and require() calls
|
||||
- Re-exports and barrel file entries
|
||||
- Test files and mocks
|
||||
Do not assume a single grep caught everything.
|
||||
1
frontend/CLAUDE.md
Symbolic link
1
frontend/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -50,7 +50,7 @@
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/ui": "0.0.22",
|
||||
"@signozhq/ui": "0.0.23",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
|
||||
@@ -17,8 +17,14 @@ const BANNED_COMPONENTS = {
|
||||
Typography:
|
||||
'Use @signozhq/ui/typography Typography instead of antd Typography.',
|
||||
Switch: 'Use @signozhq/ui/switch Switch instead of antd Switch.',
|
||||
Dropdown:
|
||||
'Use @signozhq/ui DropdownMenuSimple (or the composable DropdownMenu primitives) from @signozhq/ui/dropdown-menu instead of antd Dropdown.',
|
||||
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
|
||||
Radio:
|
||||
'Use @signozhq/ui/radio-group RadioGroup (dots) or @signozhq/ui/toggle-group ToggleGroup (segmented buttons) instead of antd Radio.',
|
||||
Progress: 'Use @signozhq/ui/progress instead of antd Progress.',
|
||||
Avatar: 'Use @signozhq/ui/avatar instead of antd Avatar.',
|
||||
Divider: 'Use @signozhq/ui/divider Divider instead of antd Divider.',
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
10
frontend/pnpm-lock.yaml
generated
10
frontend/pnpm-lock.yaml
generated
@@ -77,8 +77,8 @@ importers:
|
||||
specifier: 0.0.2
|
||||
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/ui':
|
||||
specifier: 0.0.22
|
||||
version: 0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
specifier: 0.0.23
|
||||
version: 0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -3279,8 +3279,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
|
||||
'@signozhq/ui@0.0.22':
|
||||
resolution: {integrity: sha512-CJDyA4H+uXG/U2/d7/nRMNY6WIW0YWc843mfzUQALjm+xOhbO4T+qt67THjV4s1wTMs1cZLkmScbMddf+hXLIQ==}
|
||||
'@signozhq/ui@0.0.23':
|
||||
resolution: {integrity: sha512-JqIYlVHksPf07rLGWm1mgV+qpaTFfXIrXUdW0YsDN57wnW5Mu2TaMcertegJVJz/XK/sWcUVVCGXwmx1F//wqQ==}
|
||||
peerDependencies:
|
||||
'@signozhq/icons': 0.3.0
|
||||
react: ^18.2.0
|
||||
@@ -12041,7 +12041,7 @@ snapshots:
|
||||
- react-dom
|
||||
- tailwindcss
|
||||
|
||||
'@signozhq/ui@0.0.22(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.0.23(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
|
||||
@@ -54,5 +54,12 @@
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
|
||||
"MCP_SERVER": "SigNoz | MCP Server"
|
||||
"MCP_SERVER": "SigNoz | MCP Server",
|
||||
"AI_ASSISTANT": "SigNoz | AI Assistant",
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
}
|
||||
|
||||
@@ -77,5 +77,12 @@
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
|
||||
"MCP_SERVER": "SigNoz | MCP Server"
|
||||
"MCP_SERVER": "SigNoz | MCP Server",
|
||||
"AI_ASSISTANT": "SigNoz | AI Assistant",
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
}
|
||||
|
||||
@@ -102,7 +102,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
|
||||
if (
|
||||
(pathname === ROUTES.AI_ASSISTANT_BASE ||
|
||||
pathname.startsWith('/ai-assistant/')) &&
|
||||
!isAIAssistantEnabled
|
||||
) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -229,18 +229,18 @@ function App(): JSX.Element {
|
||||
}
|
||||
|
||||
setRoutes((prev) => {
|
||||
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
const hasAi = prev.some((r) => r.key === 'AI_ASSISTANT');
|
||||
if (isAIAssistantEnabled === hasAi) {
|
||||
return prev;
|
||||
}
|
||||
if (isAIAssistantEnabled) {
|
||||
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
const aiRoute = defaultRoutes.find((r) => r.key === 'AI_ASSISTANT');
|
||||
if (!aiRoute) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
|
||||
return [...prev.filter((r) => r.key !== 'AI_ASSISTANT'), aiRoute];
|
||||
}
|
||||
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
|
||||
return prev.filter((r) => r.key !== 'AI_ASSISTANT');
|
||||
});
|
||||
}, [isLoggedInState, isAIAssistantEnabled]);
|
||||
|
||||
@@ -254,6 +254,7 @@ function App(): JSX.Element {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
|
||||
@@ -501,7 +501,7 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
|
||||
@@ -40,6 +40,7 @@ export function setAIBackendUrl(url: string | null): void {
|
||||
if (aiBackendUrl === url) {
|
||||
return;
|
||||
}
|
||||
|
||||
aiBackendUrl = url;
|
||||
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ export enum ApplyFilterSignalDTO {
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
}
|
||||
export enum ApprovalStateDTO {
|
||||
pending = 'pending',
|
||||
approved = 'approved',
|
||||
rejected = 'rejected',
|
||||
superseded = 'superseded',
|
||||
}
|
||||
export enum ApprovalActionTypeDTO {
|
||||
modify = 'modify',
|
||||
delete = 'delete',
|
||||
}
|
||||
/**
|
||||
* Resolved approval (approved/rejected/superseded) anchored on the assistant message that proposed it. Pending approvals never appear here - they live at the top-level pendingApproval slot.
|
||||
*/
|
||||
@@ -63,16 +73,6 @@ export interface ApprovalActionSummaryDTO {
|
||||
resolvedAt: string;
|
||||
}
|
||||
|
||||
export enum ApprovalActionTypeDTO {
|
||||
modify = 'modify',
|
||||
delete = 'delete',
|
||||
}
|
||||
export enum ApprovalStateDTO {
|
||||
pending = 'pending',
|
||||
approved = 'approved',
|
||||
rejected = 'rejected',
|
||||
superseded = 'superseded',
|
||||
}
|
||||
export type ApprovalSummaryDTODiff = { [key: string]: unknown };
|
||||
|
||||
export interface ApprovalSummaryDTO {
|
||||
@@ -139,6 +139,16 @@ export interface CancelRequestDTO {
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export enum ExecutionStateDTO {
|
||||
queued = 'queued',
|
||||
running = 'running',
|
||||
awaiting_approval = 'awaiting_approval',
|
||||
awaiting_clarification = 'awaiting_clarification',
|
||||
resumed = 'resumed',
|
||||
completed = 'completed',
|
||||
failed = 'failed',
|
||||
canceled = 'canceled',
|
||||
}
|
||||
export interface CancelResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -153,6 +163,13 @@ export type ClarificationFieldDTOOptions = string[] | null;
|
||||
|
||||
export type ClarificationFieldDTODefault = string | string[] | null;
|
||||
|
||||
export enum ClarificationFieldTypeDTO {
|
||||
text = 'text',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multi_select = 'multi_select',
|
||||
boolean = 'boolean',
|
||||
}
|
||||
export interface ClarificationFieldDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -175,13 +192,6 @@ export interface ClarificationFieldDTO {
|
||||
default?: ClarificationFieldDTODefault;
|
||||
}
|
||||
|
||||
export enum ClarificationFieldTypeDTO {
|
||||
text = 'text',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multi_select = 'multi_select',
|
||||
boolean = 'boolean',
|
||||
}
|
||||
export enum ClarificationStateDTO {
|
||||
pending = 'pending',
|
||||
submitted = 'submitted',
|
||||
@@ -252,178 +262,21 @@ export interface ClarifyResponseDTO {
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export type CreateMessageRequestDTOContexts = MessageContextDTO[] | null;
|
||||
|
||||
export type CreateMessageRequestDTOForkFromMessageId = string | null;
|
||||
|
||||
export interface CreateMessageRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @maxLength 20000
|
||||
* @minLength 1
|
||||
*/
|
||||
content: string;
|
||||
contexts?: CreateMessageRequestDTOContexts;
|
||||
forkFromMessageId?: CreateMessageRequestDTOForkFromMessageId;
|
||||
}
|
||||
|
||||
export interface CreateMessageResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export type CreateThreadRequestDTOTitle = string | null;
|
||||
|
||||
export interface CreateThreadRequestDTO {
|
||||
title?: CreateThreadRequestDTOTitle;
|
||||
}
|
||||
|
||||
export interface CreateThreadResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
|
||||
|
||||
export type ErrorBodyDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Inner error object — matches Go ErrorsJSON.
|
||||
*/
|
||||
export interface ErrorBodyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
errors?: ErrorBodyDTOErrors;
|
||||
url?: ErrorBodyDTOUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level error envelope — matches Go RenderErrorResponse.
|
||||
*/
|
||||
export interface ErrorResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
error: ErrorBodyDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single sub-error entry — matches Go ErrorsResponseerroradditional.
|
||||
*/
|
||||
export interface ErrorResponseAdditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export enum ExecutionStateDTO {
|
||||
queued = 'queued',
|
||||
running = 'running',
|
||||
awaiting_approval = 'awaiting_approval',
|
||||
awaiting_clarification = 'awaiting_clarification',
|
||||
resumed = 'resumed',
|
||||
completed = 'completed',
|
||||
failed = 'failed',
|
||||
canceled = 'canceled',
|
||||
}
|
||||
export enum FeedbackRatingDTO {
|
||||
positive = 'positive',
|
||||
negative = 'negative',
|
||||
}
|
||||
export type FeedbackRequestDTOComment = string | null;
|
||||
|
||||
export interface FeedbackRequestDTO {
|
||||
rating: FeedbackRatingDTO;
|
||||
comment?: FeedbackRequestDTOComment;
|
||||
}
|
||||
|
||||
export interface FeedbackResponseDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HTTPValidationErrorDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
detail?: ValidationErrorDTO[];
|
||||
}
|
||||
|
||||
export const HealthResponseDTOValue = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: 'ok',
|
||||
} as const;
|
||||
export type HealthResponseDTO = typeof HealthResponseDTOValue;
|
||||
|
||||
export type MessageActionDTOActionMetadataId = string | null;
|
||||
|
||||
export type MessageActionDTOResourceType = string | null;
|
||||
|
||||
export type MessageActionDTOResourceId = string | null;
|
||||
|
||||
export type MessageActionDTOState = string | null;
|
||||
|
||||
export type MessageActionDTOInputAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOInput = MessageActionDTOInputAnyOf | null;
|
||||
|
||||
export type MessageActionDTOTooltip = string | null;
|
||||
|
||||
export type MessageActionDTOSignal = ApplyFilterSignalDTO | null;
|
||||
|
||||
export type MessageActionDTOQueryAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label: string;
|
||||
actionMetadataId?: MessageActionDTOActionMetadataId;
|
||||
resourceType?: MessageActionDTOResourceType;
|
||||
resourceId?: MessageActionDTOResourceId;
|
||||
state?: MessageActionDTOState;
|
||||
input?: MessageActionDTOInput;
|
||||
tooltip?: MessageActionDTOTooltip;
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
}
|
||||
|
||||
export enum MessageActionKindDTO {
|
||||
undo = 'undo',
|
||||
revert = 'revert',
|
||||
restore = 'restore',
|
||||
follow_up = 'follow_up',
|
||||
open_resource = 'open_resource',
|
||||
open_docs = 'open_docs',
|
||||
apply_filter = 'apply_filter',
|
||||
}
|
||||
export enum MessageContentTypeDTO {
|
||||
markdown = 'markdown',
|
||||
* Identifier exposed on the wire for each counter row.
|
||||
|
||||
Mirrors the ``RateLimitCounterType`` model enum minus the cost
|
||||
counter. The daily-cost limit is enforced internally (Redis
|
||||
counter + 429 from the pre-flight gate) but never surfaced on the
|
||||
customer-facing API: shipping the raw provider cost to tenant users
|
||||
pins our public pricing model to what we pay Anthropic and forecloses
|
||||
markup, per-seat bundling, or tiered pricing. Cost stays internal on
|
||||
``assistant_executions`` + Redis for billing.
|
||||
*/
|
||||
export enum CounterTypeNameDTO {
|
||||
hourly_message = 'hourly_message',
|
||||
daily_message = 'daily_message',
|
||||
daily_token = 'daily_token',
|
||||
}
|
||||
/**
|
||||
* "auto" if derived from current page; "mention" if explicitly @-picked.
|
||||
@@ -482,6 +335,193 @@ export interface MessageContextDTO {
|
||||
metadata?: MessageContextDTOMetadata;
|
||||
}
|
||||
|
||||
export type CreateMessageRequestDTOContexts = MessageContextDTO[] | null;
|
||||
|
||||
export type CreateMessageRequestDTOForkFromMessageId = string | null;
|
||||
|
||||
export interface CreateMessageRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @maxLength 20000
|
||||
* @minLength 1
|
||||
*/
|
||||
content: string;
|
||||
contexts?: CreateMessageRequestDTOContexts;
|
||||
forkFromMessageId?: CreateMessageRequestDTOForkFromMessageId;
|
||||
}
|
||||
|
||||
export interface CreateMessageResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
executionId: string;
|
||||
}
|
||||
|
||||
export type CreateThreadRequestDTOTitle = string | null;
|
||||
|
||||
export interface CreateThreadRequestDTO {
|
||||
title?: CreateThreadRequestDTOTitle;
|
||||
}
|
||||
|
||||
export interface CreateThreadResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format uuid
|
||||
*/
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single sub-error entry — matches Go ErrorsResponseerroradditional.
|
||||
*/
|
||||
export interface ErrorResponseAdditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
|
||||
|
||||
export type ErrorBodyDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Inner error object — matches Go ErrorsJSON.
|
||||
*/
|
||||
export interface ErrorBodyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
errors?: ErrorBodyDTOErrors;
|
||||
url?: ErrorBodyDTOUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level error envelope — matches Go RenderErrorResponse.
|
||||
*/
|
||||
export interface ErrorResponseDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
error: ErrorBodyDTO;
|
||||
}
|
||||
|
||||
export enum FeedbackRatingDTO {
|
||||
positive = 'positive',
|
||||
negative = 'negative',
|
||||
}
|
||||
export type FeedbackRequestDTOComment = string | null;
|
||||
|
||||
export interface FeedbackRequestDTO {
|
||||
rating: FeedbackRatingDTO;
|
||||
comment?: FeedbackRequestDTOComment;
|
||||
}
|
||||
|
||||
export interface FeedbackResponseDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ValidationErrorDTOLocItem = string | number;
|
||||
|
||||
export type ValidationErrorDTOCtx = { [key: string]: unknown };
|
||||
|
||||
export interface ValidationErrorDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
loc: ValidationErrorDTOLocItem[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
msg: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
input?: unknown;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
ctx?: ValidationErrorDTOCtx;
|
||||
}
|
||||
|
||||
export interface HTTPValidationErrorDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
detail?: ValidationErrorDTO[];
|
||||
}
|
||||
|
||||
export const HealthResponseDTOValue = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: 'ok',
|
||||
} as const;
|
||||
export type HealthResponseDTO = typeof HealthResponseDTOValue;
|
||||
|
||||
export type MessageActionDTOActionMetadataId = string | null;
|
||||
|
||||
export type MessageActionDTOResourceType = string | null;
|
||||
|
||||
export type MessageActionDTOResourceId = string | null;
|
||||
|
||||
export type MessageActionDTOState = string | null;
|
||||
|
||||
export type MessageActionDTOInputAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOInput = MessageActionDTOInputAnyOf | null;
|
||||
|
||||
export type MessageActionDTOTooltip = string | null;
|
||||
|
||||
export type MessageActionDTOSignal = ApplyFilterSignalDTO | null;
|
||||
|
||||
export type MessageActionDTOQueryAnyOf = { [key: string]: unknown };
|
||||
|
||||
export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionDTOUrl = string | null;
|
||||
|
||||
export enum MessageActionKindDTO {
|
||||
undo = 'undo',
|
||||
revert = 'revert',
|
||||
restore = 'restore',
|
||||
follow_up = 'follow_up',
|
||||
open_resource = 'open_resource',
|
||||
open_docs = 'open_docs',
|
||||
apply_filter = 'apply_filter',
|
||||
}
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label: string;
|
||||
actionMetadataId?: MessageActionDTOActionMetadataId;
|
||||
resourceType?: MessageActionDTOResourceType;
|
||||
resourceId?: MessageActionDTOResourceId;
|
||||
state?: MessageActionDTOState;
|
||||
input?: MessageActionDTOInput;
|
||||
tooltip?: MessageActionDTOTooltip;
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
}
|
||||
|
||||
export enum MessageContentTypeDTO {
|
||||
markdown = 'markdown',
|
||||
}
|
||||
export enum MessageRoleDTO {
|
||||
user = 'user',
|
||||
assistant = 'assistant',
|
||||
@@ -616,6 +656,10 @@ export interface RevertRequestDTO {
|
||||
actionMetadataId: string;
|
||||
}
|
||||
|
||||
export enum ScopeDTO {
|
||||
user = 'user',
|
||||
org = 'org',
|
||||
}
|
||||
export type ThreadDetailResponseDTOTitle = string | null;
|
||||
|
||||
export type ThreadDetailResponseDTOState = ExecutionStateDTO | null;
|
||||
@@ -663,18 +707,6 @@ export interface ThreadDetailResponseDTO {
|
||||
|
||||
export type ThreadListResponseDTONextCursor = string | null;
|
||||
|
||||
export interface ThreadListResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
threads: ThreadSummaryDTO[];
|
||||
nextCursor?: ThreadListResponseDTONextCursor;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export type ThreadSummaryDTOTitle = string | null;
|
||||
|
||||
export type ThreadSummaryDTOState = ExecutionStateDTO | null;
|
||||
@@ -709,6 +741,18 @@ export interface ThreadSummaryDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
threads: ThreadSummaryDTO[];
|
||||
nextCursor?: ThreadListResponseDTONextCursor;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export interface UndoRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -726,28 +770,29 @@ export interface UpdateThreadRequestDTO {
|
||||
archived?: UpdateThreadRequestDTOArchived;
|
||||
}
|
||||
|
||||
export type ValidationErrorDTOLocItem = string | number;
|
||||
export type UsageResponseDTONextPage = string | null;
|
||||
|
||||
export type ValidationErrorDTOCtx = { [key: string]: unknown };
|
||||
/**
|
||||
* One row in the ``GET /usage`` response.
|
||||
*/
|
||||
export interface UsageRowDTO {
|
||||
type: CounterTypeNameDTO;
|
||||
scope: ScopeDTO;
|
||||
used: number;
|
||||
limit: number;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
resetsAt: string;
|
||||
}
|
||||
|
||||
export interface ValidationErrorDTO {
|
||||
export interface UsageResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
loc: ValidationErrorDTOLocItem[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
msg: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
input?: unknown;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
ctx?: ValidationErrorDTOCtx;
|
||||
data: UsageRowDTO[];
|
||||
nextPage?: UsageResponseDTONextPage;
|
||||
}
|
||||
|
||||
export type ApprovalEventDTODiff = { [key: string]: unknown };
|
||||
@@ -909,6 +954,20 @@ export interface ErrorEventDTO {
|
||||
retryAction?: RetryActionDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection SSE keep-alive emitted every `sse_heartbeat_interval_seconds`.
|
||||
|
||||
Carries no `executionId` and no `eventId` — heartbeats are wire-level
|
||||
keep-alives, not part of the replayable event log.
|
||||
*/
|
||||
export const HeartbeatEventDTOValue = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: 'heartbeat',
|
||||
} as const;
|
||||
export type HeartbeatEventDTO = typeof HeartbeatEventDTOValue;
|
||||
|
||||
export type MessageActionEventDTOActionMetadataId = string | null;
|
||||
|
||||
export type MessageActionEventDTOResourceType = string | null;
|
||||
@@ -1315,3 +1374,14 @@ export type SubmitFeedbackApiV1AssistantMessagesMessageIdFeedbackPostHeaders = {
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
export type GetUsageApiV1AssistantUsageGetHeaders = {
|
||||
/**
|
||||
* @description SigNoz auth token (Bearer or raw JWT)
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* @description SigNoz instance base URL for multi-tenant deployments. Falls back to SIGNOZ_API_URL env var when omitted.
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
@@ -18,11 +18,15 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
GetDashboardV2PathParameters,
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
@@ -628,3 +632,187 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
|
||||
* @summary Create dashboard (v2)
|
||||
*/
|
||||
export const createDashboardV2 = (
|
||||
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateDashboardV2201>({
|
||||
url: `/api/v2/dashboards`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardV2DTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardV2>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardV2>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createDashboardV2'];
|
||||
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 createDashboardV2>>,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createDashboardV2(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createDashboardV2>>
|
||||
>;
|
||||
export type CreateDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardV2DTO>
|
||||
| undefined;
|
||||
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create dashboard (v2)
|
||||
*/
|
||||
export const useCreateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDashboardV2>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createDashboardV2>>,
|
||||
TError,
|
||||
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns a v2-shape dashboard.
|
||||
* @summary Get dashboard (v2)
|
||||
*/
|
||||
export const getDashboardV2 = (
|
||||
{ id }: GetDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetDashboardV2QueryKey = ({
|
||||
id,
|
||||
}: GetDashboardV2PathParameters) => {
|
||||
return [`/api/v2/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetDashboardV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getDashboardV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetDashboardV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getDashboardV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetDashboardV2QueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDashboardV2>>> = ({
|
||||
signal,
|
||||
}) => getDashboardV2({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getDashboardV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetDashboardV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getDashboardV2>>
|
||||
>;
|
||||
export type GetDashboardV2QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get dashboard (v2)
|
||||
*/
|
||||
|
||||
export function useGetDashboardV2<
|
||||
TData = Awaited<ReturnType<typeof getDashboardV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetDashboardV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getDashboardV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetDashboardV2QueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get dashboard (v2)
|
||||
*/
|
||||
export const invalidateGetDashboardV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetDashboardV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetDashboardV2QueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { Breadcrumb, Divider } from 'antd';
|
||||
import { Breadcrumb } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
|
||||
import styles from './AlertBreadcrumb.module.scss';
|
||||
import BreadcrumbItem, { BreadcrumbItemConfig } from './BreadcrumbItem';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LifeBuoy, RefreshCw, TriangleAlert } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import styles from './ErrorEmptyState.module.scss';
|
||||
|
||||
interface ErrorEmptyStateProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function ErrorEmptyState({
|
||||
title = 'Something went wrong',
|
||||
subtitle = 'Our team is getting on top to resolve this. Please reach out to support if the issue persists.',
|
||||
onRefresh,
|
||||
}: ErrorEmptyStateProps): JSX.Element {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const onContactSupport = useCallback((): void => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="error-empty-state">
|
||||
<TriangleAlert className={styles.icon} size={32} />
|
||||
<div className={styles.title} data-testid="error-title">
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.subtitle} data-testid="error-subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={onContactSupport}
|
||||
data-testid="error-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="error-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorEmptyState;
|
||||
1
frontend/src/components/Alerts/ErrorEmptyState/index.ts
Normal file
1
frontend/src/components/Alerts/ErrorEmptyState/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ErrorEmptyState';
|
||||
@@ -0,0 +1,68 @@
|
||||
.labelColumn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overflowTrigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflowBadge {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelTooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
142
frontend/src/components/Alerts/LabelColumn/LabelColumn.test.tsx
Normal file
142
frontend/src/components/Alerts/LabelColumn/LabelColumn.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
import LabelColumn from './LabelColumn';
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
function triggerResize(width: number): void {
|
||||
if (resizeCallback) {
|
||||
act(() => {
|
||||
resizeCallback?.(
|
||||
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeCallback = null;
|
||||
});
|
||||
|
||||
function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
): ReturnType<typeof render> {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
describe('LabelColumn', () => {
|
||||
it('should render all labels when 5 or fewer', () => {
|
||||
const labels = ['env', 'service', 'region'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should truncate labels and show +N badge when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container that fits ~3 badges
|
||||
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
|
||||
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
|
||||
triggerResize(220);
|
||||
|
||||
// First 3 visible
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
|
||||
|
||||
// Remaining in overflow badge
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
|
||||
});
|
||||
|
||||
it('should render label with value when value prop provided', () => {
|
||||
const labels = ['env'];
|
||||
const value = { env: 'production' };
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} value={value} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
|
||||
'env: production',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render labels without value when value is not provided for that label', () => {
|
||||
const labels = ['env', 'service'];
|
||||
const value = { env: 'production' };
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} value={value} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
|
||||
'env: production',
|
||||
);
|
||||
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
|
||||
});
|
||||
|
||||
it('should show overflow badge with remaining count when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container to trigger overflow (shows 3 labels)
|
||||
// 220px fits first 3 badges before overflow
|
||||
triggerResize(220);
|
||||
|
||||
// Overflow badge shows +3 (remaining labels)
|
||||
const overflowBadge = screen.getByTestId('label-overflow-badge');
|
||||
expect(overflowBadge).toBeInTheDocument();
|
||||
expect(overflowBadge).toHaveTextContent('+3');
|
||||
});
|
||||
|
||||
it('should render empty when no labels provided', () => {
|
||||
renderWithProviders(<LabelColumn labels={[]} />);
|
||||
|
||||
const column = screen.getByTestId('label-column');
|
||||
expect(column.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use primary color by default', () => {
|
||||
const labels = ['env'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all labels when container is wide enough', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate wide container
|
||||
triggerResize(1000);
|
||||
|
||||
// All labels visible
|
||||
labels.forEach((label) => {
|
||||
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No overflow badge
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
150
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
150
frontend/src/components/Alerts/LabelColumn/LabelColumn.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import LabelTag from './LabelTag';
|
||||
|
||||
import styles from './LabelColumn.module.scss';
|
||||
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
|
||||
|
||||
export interface LabelColumnProps {
|
||||
labels: string[];
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
value?: { [key: string]: string };
|
||||
}
|
||||
|
||||
function LabelColumn({
|
||||
labels,
|
||||
value,
|
||||
color = 'primary',
|
||||
}: LabelColumnProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const calculateMaxVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const label of labels) {
|
||||
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[labels, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
if (container.clientWidth > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
|
||||
}
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateMaxVisible]);
|
||||
|
||||
const needsOverflow = labels.length > maxVisibleCount;
|
||||
const visibleLabels = needsOverflow
|
||||
? labels.slice(0, maxVisibleCount)
|
||||
: labels;
|
||||
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.labelColumn}
|
||||
data-testid="label-column"
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
|
||||
))}
|
||||
{remainingLabels.length > 0 && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.overflowBadge}
|
||||
variant="outline"
|
||||
data-testid="label-overflow-badge"
|
||||
>
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end">
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>
|
||||
{remainingLabels
|
||||
.map((label) => (value?.[label] ? `${label}: ${value[label]}` : label))
|
||||
.join(', ')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
const searchFormat = remainingLabels
|
||||
.map((label) => (value?.[label] ? `${label} ${value[label]}` : label))
|
||||
.join(' ');
|
||||
copyToClipboard(searchFormat);
|
||||
toast.success('Copied! Use in search to filter alerts.');
|
||||
}}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelColumn;
|
||||
@@ -0,0 +1,30 @@
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
74
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal file
74
frontend/src/components/Alerts/LabelColumn/LabelTag.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LabelTag.module.scss';
|
||||
|
||||
export interface LabelTagProps {
|
||||
label: string;
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
const searchFormat = value ? `${label} ${value}` : label;
|
||||
|
||||
const handleCopy = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(searchFormat);
|
||||
toast.success('Copied! Use in search to filter alerts.');
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>{displayText}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelTag;
|
||||
2
frontend/src/components/Alerts/LabelColumn/index.ts
Normal file
2
frontend/src/components/Alerts/LabelColumn/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './LabelColumn';
|
||||
export type { LabelColumnProps } from './LabelColumn';
|
||||
14
frontend/src/components/Alerts/LabelColumn/utils.ts
Normal file
14
frontend/src/components/Alerts/LabelColumn/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const BADGE_GAP = 4;
|
||||
export const OVERFLOW_BADGE_WIDTH = 40;
|
||||
|
||||
export const BADGE_MAX_WIDTH = 180;
|
||||
export const BADGE_PADDING = 16;
|
||||
export const CHAR_WIDTH = 7;
|
||||
|
||||
export function estimateBadgeWidth(label: string, value?: string): number {
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
return Math.min(
|
||||
displayText.length * CHAR_WIDTH + BADGE_PADDING,
|
||||
BADGE_MAX_WIDTH,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import NoResultsEmptyState from './NoResultsEmptyState';
|
||||
|
||||
describe('NoResultsEmptyState', () => {
|
||||
it('should render with default props', () => {
|
||||
render(<NoResultsEmptyState />);
|
||||
|
||||
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'No matching results',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'No items match your current filters. Try adjusting your search criteria.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render with custom title and subtitle', () => {
|
||||
render(
|
||||
<NoResultsEmptyState title="Custom Title" subtitle="Custom Subtitle" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
|
||||
'Custom Title',
|
||||
);
|
||||
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
|
||||
'Custom Subtitle',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render clear button when onClear is not provided', () => {
|
||||
render(<NoResultsEmptyState />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('no-results-clear-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render clear button when onClear is provided', () => {
|
||||
const onClear = jest.fn();
|
||||
|
||||
render(<NoResultsEmptyState onClear={onClear} />);
|
||||
|
||||
expect(screen.getByTestId('no-results-clear-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
|
||||
'Clear Filters',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render custom clear button text', () => {
|
||||
render(
|
||||
<NoResultsEmptyState onClear={jest.fn()} clearButtonText="Reset All" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
|
||||
'Reset All',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onClear when clear button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
|
||||
render(<NoResultsEmptyState onClear={onClear} />);
|
||||
|
||||
await user.click(screen.getByTestId('no-results-clear-button'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RefreshCw, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './NoResultsEmptyState.module.scss';
|
||||
|
||||
interface NoResultsEmptyStateProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onClear?: () => void;
|
||||
clearButtonText?: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
function NoResultsEmptyState({
|
||||
title = 'No matching results',
|
||||
subtitle = 'No items match your current filters. Try adjusting your search criteria.',
|
||||
onClear,
|
||||
clearButtonText = 'Clear Filters',
|
||||
onRefresh,
|
||||
}: NoResultsEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="no-results-empty-state">
|
||||
<Search className={styles.icon} size={16} />
|
||||
<div className={styles.title} data-testid="no-results-title">
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.subtitle} data-testid="no-results-subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{onClear && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClear}
|
||||
data-testid="no-results-clear-button"
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="no-results-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoResultsEmptyState;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './NoResultsEmptyState';
|
||||
32
frontend/src/components/Alerts/constants.ts
Normal file
32
frontend/src/components/Alerts/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { BadgeColor } from '@signozhq/ui/badge';
|
||||
|
||||
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
|
||||
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
|
||||
|
||||
export const STATE_LABELS: Record<string, string> = {
|
||||
firing: 'Firing',
|
||||
pending: 'Pending',
|
||||
inactive: 'OK',
|
||||
disabled: 'Disabled',
|
||||
};
|
||||
|
||||
export const STATE_COLORS: Record<string, string> = {
|
||||
firing: 'var(--bg-cherry-500)',
|
||||
pending: 'var(--bg-amber-500)',
|
||||
inactive: 'var(--bg-forest-500)',
|
||||
disabled: 'var(--l2-foreground)',
|
||||
};
|
||||
|
||||
export const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: 'var(--bg-cherry-500)',
|
||||
error: 'var(--bg-cherry-400)',
|
||||
warning: 'var(--bg-amber-500)',
|
||||
info: 'var(--bg-robin-500)',
|
||||
};
|
||||
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
|
||||
critical: 'error',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'primary',
|
||||
};
|
||||
7
frontend/src/components/Alerts/types.ts
Normal file
7
frontend/src/components/Alerts/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface FilterValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertWithLabels {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
287
frontend/src/components/Alerts/utils.test.ts
Normal file
287
frontend/src/components/Alerts/utils.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertWithLabels, FilterValue } from './types';
|
||||
import { filterByLabels, searchByLabels, sortByColumn } from './utils';
|
||||
|
||||
interface TestAlert extends AlertWithLabels {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const createAlert = (
|
||||
name: string,
|
||||
value: number,
|
||||
labels?: Record<string, string>,
|
||||
): TestAlert => ({
|
||||
name,
|
||||
value,
|
||||
labels,
|
||||
});
|
||||
|
||||
describe('sortByColumn', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('Alert C', 3),
|
||||
createAlert('Alert A', 1),
|
||||
createAlert('Alert B', 2),
|
||||
];
|
||||
|
||||
const getSortValue = (
|
||||
item: TestAlert,
|
||||
columnName: string,
|
||||
): string | number => {
|
||||
if (columnName === 'name') {
|
||||
return item.name;
|
||||
}
|
||||
if (columnName === 'value') {
|
||||
return item.value;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
it('should return items unchanged when no orderBy provided', () => {
|
||||
const result = sortByColumn(alerts, null, getSortValue);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should sort by string column ascending', () => {
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.name)).toStrictEqual([
|
||||
'Alert A',
|
||||
'Alert B',
|
||||
'Alert C',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by string column descending', () => {
|
||||
const orderBy: SortState = { columnName: 'name', order: 'desc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.name)).toStrictEqual([
|
||||
'Alert C',
|
||||
'Alert B',
|
||||
'Alert A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by number column ascending', () => {
|
||||
const orderBy: SortState = { columnName: 'value', order: 'asc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should sort by number column descending', () => {
|
||||
const orderBy: SortState = { columnName: 'value', order: 'desc' };
|
||||
const result = sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('should use defaultSort when orderBy is null', () => {
|
||||
const defaultSort: SortState = { columnName: 'value', order: 'asc' };
|
||||
const result = sortByColumn(alerts, null, getSortValue, defaultSort);
|
||||
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should not mutate original array', () => {
|
||||
const original = [...alerts];
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
sortByColumn(alerts, orderBy, getSortValue);
|
||||
expect(alerts).toStrictEqual(original);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = sortByColumn(
|
||||
[],
|
||||
{ columnName: 'name', order: 'asc' },
|
||||
getSortValue,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should handle equal values', () => {
|
||||
const duplicates = [
|
||||
createAlert('Same', 1),
|
||||
createAlert('Same', 1),
|
||||
createAlert('Same', 1),
|
||||
];
|
||||
const orderBy: SortState = { columnName: 'name', order: 'asc' };
|
||||
const result = sortByColumn(duplicates, orderBy, getSortValue);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByLabels', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('CPU High', 1, { severity: 'critical', team: 'infra' }),
|
||||
createAlert('Memory Warning', 2, { severity: 'warning', team: 'backend' }),
|
||||
createAlert('Disk Full', 3, { severity: 'error', region: 'us-east' }),
|
||||
createAlert('Network Slow', 4, {}),
|
||||
createAlert('No Labels', 5),
|
||||
];
|
||||
|
||||
const getAlertName = (alert: TestAlert): string => alert.name;
|
||||
|
||||
it('should return all items when search is empty', () => {
|
||||
const result = searchByLabels(alerts, '', getAlertName);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should return all items when search is whitespace', () => {
|
||||
const result = searchByLabels(alerts, ' ', getAlertName);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should search by alert name', () => {
|
||||
const result = searchByLabels(alerts, 'CPU', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by alert name case-insensitive', () => {
|
||||
const result = searchByLabels(alerts, 'cpu', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by severity label', () => {
|
||||
const result = searchByLabels(alerts, 'critical', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should search by any label key', () => {
|
||||
const result = searchByLabels(alerts, 'team', getAlertName);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should search by any label value', () => {
|
||||
const result = searchByLabels(alerts, 'infra', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
|
||||
it('should handle alerts with no labels', () => {
|
||||
const result = searchByLabels(alerts, 'No Labels', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('No Labels');
|
||||
});
|
||||
|
||||
it('should handle partial matches', () => {
|
||||
const result = searchByLabels(alerts, 'warn', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Memory Warning');
|
||||
});
|
||||
|
||||
it('should return empty for no matches', () => {
|
||||
const result = searchByLabels(alerts, 'nonexistent', getAlertName);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should trim search text', () => {
|
||||
const result = searchByLabels(alerts, ' CPU ', getAlertName);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('CPU High');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByLabels', () => {
|
||||
const alerts: TestAlert[] = [
|
||||
createAlert('A1', 1, { severity: 'critical', team: 'infra', env: 'prod' }),
|
||||
createAlert('A2', 2, { severity: 'critical', team: 'backend', env: 'prod' }),
|
||||
createAlert('A3', 3, { severity: 'warning', team: 'infra', env: 'staging' }),
|
||||
createAlert('A4', 4, { severity: 'info', team: 'frontend', env: 'dev' }),
|
||||
createAlert('A5', 5, {}),
|
||||
createAlert('A6', 6),
|
||||
];
|
||||
|
||||
const createFilter = (value: string): FilterValue => ({ value });
|
||||
|
||||
it('should return all items when filters are empty', () => {
|
||||
const result = filterByLabels(alerts, []);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should return all items when filters is null-ish', () => {
|
||||
const result = filterByLabels(alerts, null as unknown as FilterValue[]);
|
||||
expect(result).toStrictEqual(alerts);
|
||||
});
|
||||
|
||||
it('should filter by single label', () => {
|
||||
const filters = [createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2']);
|
||||
});
|
||||
|
||||
it('should use OR logic for same key', () => {
|
||||
const filters = [
|
||||
createFilter('severity:critical'),
|
||||
createFilter('severity:warning'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2', 'A3']);
|
||||
});
|
||||
|
||||
it('should use AND logic for different keys', () => {
|
||||
const filters = [
|
||||
createFilter('severity:critical'),
|
||||
createFilter('team:infra'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('A1');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive keys', () => {
|
||||
const filters = [createFilter('SEVERITY:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive values', () => {
|
||||
const filters = [createFilter('severity:CRITICAL')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const filters = [createFilter(' severity : critical ')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty for invalid filter format', () => {
|
||||
const filters = [createFilter('invalid')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore invalid filters mixed with valid', () => {
|
||||
const filters = [createFilter('invalid'), createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude alerts without matching label key', () => {
|
||||
const filters = [createFilter('nonexistent:value')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude alerts with no labels', () => {
|
||||
const filters = [createFilter('severity:critical')];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result.every((a) => a.labels !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle complex AND/OR combinations', () => {
|
||||
const filters = [
|
||||
createFilter('env:prod'),
|
||||
createFilter('env:staging'),
|
||||
createFilter('team:infra'),
|
||||
];
|
||||
const result = filterByLabels(alerts, filters);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A3']);
|
||||
});
|
||||
});
|
||||
116
frontend/src/components/Alerts/utils.ts
Normal file
116
frontend/src/components/Alerts/utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { SortState } from 'components/TanStackTableView/types';
|
||||
|
||||
import type { AlertWithLabels, FilterValue } from './types';
|
||||
|
||||
/**
|
||||
* Generic sort function for alert-like data
|
||||
*/
|
||||
export function sortByColumn<T>(
|
||||
items: T[],
|
||||
orderBy: SortState | null,
|
||||
getSortValue: (item: T, columnName: string) => string | number,
|
||||
defaultSort?: SortState,
|
||||
): T[] {
|
||||
const sortState = orderBy ?? defaultSort;
|
||||
if (!sortState) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const { columnName, order } = sortState;
|
||||
const multiplier = order === 'asc' ? 1 : -1;
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const aVal = getSortValue(a, columnName);
|
||||
const bVal = getSortValue(b, columnName);
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1 * multiplier;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return 1 * multiplier;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search alerts/rules by name, severity, and all labels
|
||||
*/
|
||||
export function searchByLabels<T extends AlertWithLabels>(
|
||||
items: T[],
|
||||
searchText: string,
|
||||
getAlertName: (item: T) => string,
|
||||
): T[] {
|
||||
if (!searchText.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const value = searchText.toLowerCase().trim();
|
||||
|
||||
return items.filter((item) => {
|
||||
const alertName = getAlertName(item).toLowerCase();
|
||||
const severity = item.labels?.severity?.toLowerCase() ?? '';
|
||||
|
||||
const labelSearchString = Object.entries(item.labels ?? {})
|
||||
.map(([key, val]) => `${key} ${val}`)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return (
|
||||
alertName.includes(value) ||
|
||||
severity.includes(value) ||
|
||||
labelSearchString.includes(value)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter alerts by label key:value pairs
|
||||
* Same key uses OR logic, different keys use AND logic
|
||||
*/
|
||||
export function filterByLabels<T extends AlertWithLabels>(
|
||||
items: T[],
|
||||
selectedFilters: FilterValue[],
|
||||
): T[] {
|
||||
if (!selectedFilters?.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const validFilters = selectedFilters
|
||||
.map((e) => e.value)
|
||||
.filter((v) => v.split(':').length === 2);
|
||||
|
||||
if (!validFilters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group values by key - same key uses OR, different keys use AND
|
||||
const filtersByKey = new Map<string, string[]>();
|
||||
validFilters.forEach((f) => {
|
||||
const [key, value] = f.split(':');
|
||||
const trimmedKey = key.trim().toLowerCase();
|
||||
const trimmedValue = value.trim().toLowerCase();
|
||||
const existing = filtersByKey.get(trimmedKey) ?? [];
|
||||
existing.push(trimmedValue);
|
||||
filtersByKey.set(trimmedKey, existing);
|
||||
});
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.labels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All keys must match (AND), any value per key can match (OR)
|
||||
return Array.from(filtersByKey.entries()).every(([filterKey, values]) => {
|
||||
// Case-insensitive key lookup
|
||||
const matchingKey = Object.keys(item.labels ?? {}).find(
|
||||
(k) => k.toLowerCase() === filterKey,
|
||||
);
|
||||
if (!matchingKey) {
|
||||
return false;
|
||||
}
|
||||
const labelValue = item.labels?.[matchingKey]?.toLowerCase();
|
||||
return values.some((v) => labelValue === v);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Divider, Drawer } from 'antd';
|
||||
import { Drawer } from 'antd';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Radio, Tooltip } from 'antd';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
|
||||
@@ -63,27 +64,30 @@ export default function DownloadOptionsMenu({
|
||||
>
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<Radio.Group
|
||||
value={exportFormat}
|
||||
onChange={(e): void => setExportFormat(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadFormats.CSV}>csv</Radio>
|
||||
<Radio value={DownloadFormats.JSONL}>jsonl</Radio>
|
||||
</Radio.Group>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={DownloadFormats.CSV}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={DownloadFormats.JSONL}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="horizontal-line" />
|
||||
|
||||
<div className="row-limit">
|
||||
<Typography.Text className="title">Number of Rows</Typography.Text>
|
||||
<Radio.Group
|
||||
value={rowLimit}
|
||||
onChange={(e): void => setRowLimit(e.target.value)}
|
||||
<RadioGroup
|
||||
value={String(rowLimit)}
|
||||
onChange={(value): void => setRowLimit(Number(value))}
|
||||
>
|
||||
<Radio value={DownloadRowCounts.TEN_K}>10k</Radio>
|
||||
<Radio value={DownloadRowCounts.THIRTY_K}>30k</Radio>
|
||||
<Radio value={DownloadRowCounts.FIFTY_K}>50k</Radio>
|
||||
</Radio.Group>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.TEN_K)}>
|
||||
10k
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.THIRTY_K)}>
|
||||
30k
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.FIFTY_K)}>
|
||||
50k
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{dataSource !== DataSource.TRACES && (
|
||||
@@ -92,13 +96,12 @@ export default function DownloadOptionsMenu({
|
||||
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<Radio.Group
|
||||
value={columnsScope}
|
||||
onChange={(e): void => setColumnsScope(e.target.value)}
|
||||
>
|
||||
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
|
||||
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
|
||||
</Radio.Group>
|
||||
<RadioGroup value={columnsScope} onChange={setColumnsScope}>
|
||||
<RadioGroupItem value={DownloadColumnsScopes.ALL}>All</RadioGroupItem>
|
||||
<RadioGroupItem value={DownloadColumnsScopes.SELECTED}>
|
||||
Selected
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.dropdown-button {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
|
||||
import './DropDown.styles.scss';
|
||||
|
||||
function DropDown({
|
||||
element,
|
||||
onDropDownItemClick,
|
||||
}: {
|
||||
element: JSX.Element[];
|
||||
onDropDownItemClick?: MenuProps['onClick'];
|
||||
}): JSX.Element {
|
||||
const items: MenuProps['items'] = element.map(
|
||||
(e: JSX.Element, index: number) => ({
|
||||
label: e,
|
||||
key: index,
|
||||
}),
|
||||
);
|
||||
|
||||
const [isDdOpen, setDdOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items,
|
||||
onMouseEnter: (): void => setDdOpen(true),
|
||||
onMouseLeave: (): void => setDdOpen(false),
|
||||
onClick: (item): void => onDropDownItemClick?.(item),
|
||||
}}
|
||||
open={isDdOpen}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
className={`dropdown-button`}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
setDdOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ellipsis className="dropdown-icon" size={16} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
DropDown.defaultProps = {
|
||||
onDropDownItemClick: (): void => {},
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Popover,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { Button, Col, Popover, Row, Select, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -241,9 +233,9 @@ function ExplorerCard({
|
||||
</Popover>
|
||||
<Share2 onClick={onCopyUrlHandler} size="md" />
|
||||
{viewKey && (
|
||||
<Dropdown trigger={['click']} menu={moreOptionMenu}>
|
||||
<Ellipsis size="md" />
|
||||
</Dropdown>
|
||||
<DropdownMenuSimple menu={moreOptionMenu}>
|
||||
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</Space>
|
||||
</OffSetCol>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button, Input, Radio, RadioChangeEvent } from 'antd';
|
||||
import { Button, Input } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
@@ -101,13 +102,12 @@ function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className="feedback-modal-container">
|
||||
<div className="feedback-modal-header">
|
||||
<Radio.Group
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={activeTab}
|
||||
defaultValue={activeTab}
|
||||
optionType="button"
|
||||
className="feedback-modal-tabs"
|
||||
options={items}
|
||||
onChange={(e: RadioChangeEvent): void => setActiveTab(e.target.value)}
|
||||
onChange={setActiveTab}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
<div className="feedback-modal-content">
|
||||
|
||||
@@ -158,23 +158,23 @@
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.tab {
|
||||
> button {
|
||||
border: 1px solid var(--l1-border);
|
||||
width: 114px;
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
&[data-state='on'] {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.selected_view::before {
|
||||
background: var(--l1-border);
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import { useCopyToClipboard, useLocation } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider, Drawer, Radio, Tooltip } from 'antd';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import cx from 'classnames';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
@@ -197,8 +198,8 @@ function LogDetailInner({
|
||||
|
||||
const LogJsonData = log ? aggregateAttributesResourcesToString(log) : '';
|
||||
|
||||
const handleModeChange = (e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
const handleModeChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
setIsEdit(false);
|
||||
setIsFilterVisible(false);
|
||||
};
|
||||
@@ -452,56 +453,50 @@ function LogDetailInner({
|
||||
</div>
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<Radio.Group
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.OVERVIEW}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.JSON}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Braces size={14} />
|
||||
JSON
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.CONTEXT}
|
||||
>
|
||||
<div className="view-title">
|
||||
<TextSelect size={14} />
|
||||
Context
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.INFRAMETRICS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Histogram size="md" />
|
||||
Metrics
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
items={[
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.JSON,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Braces size={14} />
|
||||
JSON
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<TextSelect size={14} />
|
||||
Context
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.INFRAMETRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Histogram size="md" />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
RefreshCw,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Checkbox, Select } from 'antd';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
@@ -749,7 +750,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
@@ -1584,7 +1585,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<Checkbox checked={allOptionsSelected} className="option-checkbox">
|
||||
<Checkbox value={allOptionsSelected} className="option-checkbox">
|
||||
<div className="option-content">
|
||||
<div className="all-option-text">ALL</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
.query-add-ons {
|
||||
width: 100%;
|
||||
|
||||
.add-on-tab-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--margin-2);
|
||||
}
|
||||
}
|
||||
|
||||
.add-ons-list {
|
||||
@@ -25,7 +32,7 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.tab {
|
||||
> button {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
min-width: 120px;
|
||||
@@ -35,21 +42,21 @@
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.selected-view {
|
||||
color: var(--text-robin-500);
|
||||
border: 1px solid var(--l1-border);
|
||||
&[data-state='on'] {
|
||||
color: var(--text-robin-500);
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
display: none;
|
||||
}
|
||||
display: none;
|
||||
|
||||
.selected-view::before {
|
||||
background: var(--l1-border);
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Radio, RadioChangeEvent, Tooltip } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
@@ -250,8 +251,7 @@ function QueryAddOns({
|
||||
);
|
||||
}, [panelType, isListViewPanel, query, showReduceTo]);
|
||||
|
||||
const handleOptionClick = (e: RadioChangeEvent): void => {
|
||||
const clickedAddOn = e.target.value as AddOn;
|
||||
const handleOptionClick = (clickedAddOn: AddOn): void => {
|
||||
const isAlreadySelected = selectedViews.some(
|
||||
(view) => view.key === clickedAddOn.key,
|
||||
);
|
||||
@@ -515,15 +515,27 @@ function QueryAddOns({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-ons-list">
|
||||
<Radio.Group
|
||||
className="add-ons-tabs"
|
||||
onChange={handleOptionClick}
|
||||
value={selectedViews}
|
||||
>
|
||||
{addOns.map((addOn) => (
|
||||
<ToggleGroupSimple
|
||||
type="multiple"
|
||||
className="add-ons-tabs"
|
||||
value={selectedViews.map((view) => view.key)}
|
||||
onChange={(newKeys: string[]): void => {
|
||||
const oldKeys = selectedViews.map((view) => view.key);
|
||||
const toggledKey =
|
||||
newKeys.find((k) => !oldKeys.includes(k)) ??
|
||||
oldKeys.find((k) => !newKeys.includes(k));
|
||||
if (!toggledKey) {
|
||||
return;
|
||||
}
|
||||
const clickedAddOn = addOns.find((a) => a.key === toggledKey);
|
||||
if (clickedAddOn) {
|
||||
handleOptionClick(clickedAddOn);
|
||||
}
|
||||
}}
|
||||
items={addOns.map((addOn) => ({
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<Tooltip
|
||||
key={addOn.key}
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
@@ -534,26 +546,17 @@ function QueryAddOns({
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedViews.find((view) => view.key === addOn.key)
|
||||
? 'selected-view tab'
|
||||
: 'tab'
|
||||
}
|
||||
value={addOn}
|
||||
<span
|
||||
className="add-on-tab-title"
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
<div
|
||||
className="add-on-tab-title"
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</div>
|
||||
</Radio.Button>
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import cx from 'classnames';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -195,7 +195,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
)}
|
||||
|
||||
{isMultiQueryAllowed && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
className="query-actions-dropdown"
|
||||
menu={{
|
||||
items: [
|
||||
@@ -217,10 +217,10 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
align="end"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
|
||||
.checkbox-value-section {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Button, Checkbox, Input, Skeleton } from 'antd';
|
||||
import { Button, Input, Skeleton } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
@@ -634,10 +635,12 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
checked={currentFilterState[value]}
|
||||
onChange={(checked): void =>
|
||||
onChange(value, checked === true, false)
|
||||
}
|
||||
value={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
rootClassName="check-box"
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -4,14 +4,14 @@ import type {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps } from 'antd';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { SlidersHorizontal } from '@signozhq/icons';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ResizeTable from './ResizeTable';
|
||||
import { DynamicColumnTableProps } from './types';
|
||||
@@ -84,8 +84,9 @@ function DynamicColumnTable({
|
||||
);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] =
|
||||
const items: MenuItem[] =
|
||||
dynamicColumns?.map((column, index) => ({
|
||||
key: String(index),
|
||||
label: (
|
||||
<div
|
||||
className="dynamicColumnsTable-items"
|
||||
@@ -99,8 +100,6 @@ function DynamicColumnTable({
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
key: index,
|
||||
type: 'checkbox',
|
||||
})) || [];
|
||||
|
||||
// Get current page from URL or default to 1
|
||||
@@ -129,18 +128,14 @@ function DynamicColumnTable({
|
||||
<Flex justify="flex-end" align="center" gap={8}>
|
||||
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
|
||||
{dynamicColumns && (
|
||||
<Dropdown
|
||||
getPopupContainer={popupContainer}
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
className="dynamicColumnTable-button filter-btn"
|
||||
size="middle"
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
data-testid="additional-filters-button"
|
||||
/>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CircleAlert, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import { Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -146,12 +147,11 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
options={options}
|
||||
optionFilterProp="label"
|
||||
optionRender={(option): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={value.includes(option.value as string)}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
<div style={{ pointerEvents: 'none' }}>
|
||||
<Checkbox value={value.includes(option.value as string)}>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Control, UseFormRegister } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
@@ -60,30 +60,21 @@ function KeyFormPhase({
|
||||
name="expiryMode"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<ToggleGroup
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={field.value}
|
||||
onChange={(val): void => {
|
||||
onChange={(val: string): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="add-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.NONE}
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.DATE}
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
items={[
|
||||
{ value: ExpiryMode.NONE, label: 'No Expiration' },
|
||||
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
@@ -101,31 +101,22 @@ function EditKeyForm({
|
||||
name="expiryMode"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<ToggleGroup
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={field.value}
|
||||
onChange={(val): void => {
|
||||
onChange={(val: string): void => {
|
||||
if (val && canUpdate) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.NONE}
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ExpiryMode.DATE}
|
||||
disabled={!canUpdate}
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
items={[
|
||||
{ value: ExpiryMode.NONE, label: 'No Expiration' },
|
||||
{ value: ExpiryMode.DATE, label: 'Set Expiration Date' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
@@ -395,11 +395,11 @@ function ServiceAccountDrawer({
|
||||
const drawerContent = (
|
||||
<div className="sa-drawer__layout">
|
||||
<div className="sa-drawer__tabs">
|
||||
<ToggleGroup
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={activeTab}
|
||||
size="sm"
|
||||
onChange={(val): void => {
|
||||
onChange={(val: string): void => {
|
||||
if (val) {
|
||||
void setActiveTab(val as ServiceAccountDrawerTab);
|
||||
if (val !== ServiceAccountDrawerTab.Keys) {
|
||||
@@ -409,25 +409,30 @@ function ServiceAccountDrawer({
|
||||
}
|
||||
}}
|
||||
className="sa-drawer__tab-group"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Overview}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Keys}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<Key size={14} />
|
||||
Keys
|
||||
{keys.length > 0 && (
|
||||
<span className="sa-drawer__tab-count">{keys.length}</span>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
items={[
|
||||
{
|
||||
value: ServiceAccountDrawerTab.Overview,
|
||||
label: (
|
||||
<>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: ServiceAccountDrawerTab.Keys,
|
||||
label: (
|
||||
<>
|
||||
<Key size={14} />
|
||||
Keys
|
||||
{keys.length > 0 && (
|
||||
<span className="sa-drawer__tab-count">{keys.length}</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
.signoz-radio-group.ant-radio-group {
|
||||
.signoz-radio-group {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&.ant-radio-group-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
@@ -30,43 +24,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
> button {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
&,
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
&[data-state='on'] {
|
||||
&,
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
&::before {
|
||||
background: var(--l3-background);
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
background: var(--l3-background);
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-radio-group-disabled {
|
||||
.tab,
|
||||
.selected_view {
|
||||
&[disabled],
|
||||
&[data-disabled] {
|
||||
background: var(--l2-background) !important;
|
||||
border-color: var(--l1-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.selected_view:hover {
|
||||
background: var(--l2-background) !important;
|
||||
border-color: var(--l1-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
&:hover {
|
||||
background: var(--l2-background) !important;
|
||||
border-color: var(--l1-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Radio, RadioChangeEvent } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import './SignozRadioGroup.styles.scss';
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Option {
|
||||
interface SignozRadioGroupProps {
|
||||
value: string;
|
||||
options: Option[];
|
||||
onChange: (e: RadioChangeEvent) => void;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -24,26 +24,22 @@ function SignozRadioGroup({
|
||||
disabled = false,
|
||||
}: SignozRadioGroupProps): JSX.Element {
|
||||
return (
|
||||
<Radio.Group
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={value}
|
||||
buttonStyle="solid"
|
||||
className={`signoz-radio-group ${className}`}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Radio.Button
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={value === option.value ? 'selected_view tab' : 'tab'}
|
||||
>
|
||||
items={options.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<div className="view-title-container">
|
||||
{option.icon && <div className="icon-container">{option.icon}</div>}
|
||||
{option.label}
|
||||
</div>
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,10 @@ import {
|
||||
ButtonProps,
|
||||
Col,
|
||||
ColProps,
|
||||
Divider,
|
||||
DividerProps,
|
||||
Row,
|
||||
RowProps,
|
||||
Space,
|
||||
SpaceProps,
|
||||
TabsProps,
|
||||
} from 'antd';
|
||||
import {
|
||||
Typography,
|
||||
@@ -34,21 +31,11 @@ const StyledRow = styled(Row)<TStyledRow>`
|
||||
${styledClass}
|
||||
`;
|
||||
|
||||
type TStyledDivider = DividerProps & IStyledClass;
|
||||
const StyledDivider = styled(Divider)<TStyledDivider>`
|
||||
${styledClass}
|
||||
`;
|
||||
|
||||
type TStyledSpace = SpaceProps & IStyledClass;
|
||||
const StyledSpace = styled(Space)<TStyledSpace>`
|
||||
${styledClass}
|
||||
`;
|
||||
|
||||
type TStyledTabs = TabsProps & IStyledClass;
|
||||
const StyledTabs = styled(Divider)<TStyledTabs>`
|
||||
${styledClass}
|
||||
`;
|
||||
|
||||
type TStyledButton = ButtonProps & IStyledClass;
|
||||
const StyledButton = styled(Button)<TStyledButton>`
|
||||
${styledClass}
|
||||
@@ -79,9 +66,7 @@ export {
|
||||
StyledButton,
|
||||
StyledCol,
|
||||
StyledDiv,
|
||||
StyledDivider,
|
||||
StyledRow,
|
||||
StyledSpace,
|
||||
StyledTabs,
|
||||
StyledTypography,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Pagination } from '@signozhq/ui/pagination';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
@@ -51,7 +51,7 @@ import { useEffectiveData } from './useEffectiveData';
|
||||
import { useFlatItems } from './useFlatItems';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
import { useTableParams } from './useTableParams';
|
||||
import { buildTanstackColumnDef } from './utils';
|
||||
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
|
||||
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
@@ -66,14 +66,6 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
|
||||
|
||||
const noopColumnVisibility = (): void => {};
|
||||
|
||||
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
label: value.toString(),
|
||||
displayValue: value.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData>(
|
||||
{
|
||||
@@ -89,7 +81,6 @@ function TanStackTableInner<TData>(
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
paginationClassname,
|
||||
onSort,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
@@ -102,6 +93,7 @@ function TanStackTableInner<TData>(
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
getRowCanExpand,
|
||||
@@ -129,17 +121,22 @@ function TanStackTableInner<TData>(
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
setPage: internalSetPage,
|
||||
setLimit: internalSetLimit,
|
||||
orderBy,
|
||||
setOrderBy: internalSetOrderBy,
|
||||
expanded,
|
||||
setExpanded,
|
||||
} = useTableParams(enableQueryParams, {
|
||||
page: pagination?.defaultPage,
|
||||
limit: pagination?.defaultLimit,
|
||||
limit: pagination?.defaultLimit ?? pagination?.calculatedPageSize ?? 10,
|
||||
});
|
||||
|
||||
const pageSizeItems = useMemo(
|
||||
() => buildPageSizeItems(pagination?.calculatedPageSize),
|
||||
[pagination?.calculatedPageSize],
|
||||
);
|
||||
|
||||
const setOrderBy = useCallback(
|
||||
(sort: SortState | null) => {
|
||||
internalSetOrderBy(sort);
|
||||
@@ -148,6 +145,23 @@ function TanStackTableInner<TData>(
|
||||
[internalSetOrderBy, onSort],
|
||||
);
|
||||
|
||||
const setPage = useCallback(
|
||||
(p: number) => {
|
||||
internalSetPage(p);
|
||||
pagination?.onPageChange?.(p);
|
||||
},
|
||||
[internalSetPage, pagination],
|
||||
);
|
||||
|
||||
const setLimit = useCallback(
|
||||
(l: number) => {
|
||||
internalSetLimit(l);
|
||||
internalSetPage(1);
|
||||
pagination?.onLimitChange?.(l);
|
||||
},
|
||||
[internalSetLimit, internalSetPage, pagination],
|
||||
);
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
@@ -621,6 +635,7 @@ function TanStackTableInner<TData>(
|
||||
{pagination.showPageSize !== false && (
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
testId="pagination-page-size"
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
@@ -631,7 +646,7 @@ function TanStackTableInner<TData>(
|
||||
pagination.onPageChange?.(1);
|
||||
}
|
||||
}}
|
||||
items={paginationPageSizeItems}
|
||||
items={pageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
@@ -23,12 +23,13 @@ jest.mock('../TanStackTable.module.scss', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver for combobox tests
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
};
|
||||
beforeAll(() => {
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('TanStackTableView Integration', () => {
|
||||
describe('rendering', () => {
|
||||
@@ -402,6 +403,22 @@ describe('TanStackTableView Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves page from URL on initial mount', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
enableQueryParams: true,
|
||||
},
|
||||
queryParams: { page: '3' },
|
||||
});
|
||||
|
||||
const nav = await screen.findByRole('navigation');
|
||||
const page3Button = within(nav).getByRole('button', { name: '3' });
|
||||
|
||||
// Page 3 should be active (from URL), not reset to defaultPage 1
|
||||
expect(page3Button).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('resets page to 1 when limit changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useCalculatedPageSize } from '../useCalculatedPageSize';
|
||||
|
||||
describe('useCalculatedPageSize', () => {
|
||||
it('returns containerRef and null calculatedPageSize initially', () => {
|
||||
const { result } = renderHook(() => useCalculatedPageSize());
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
expect(result.current.containerRef.current).toBeNull();
|
||||
expect(result.current.calculatedPageSize).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts custom config', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCalculatedPageSize({
|
||||
rowHeight: 50,
|
||||
headerHeight: 40,
|
||||
paginationHeight: 50,
|
||||
minPageSize: 3,
|
||||
maxPageSize: 20,
|
||||
}),
|
||||
);
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
getPreferredPageSize,
|
||||
usePreferredPageSize,
|
||||
usePreferredPageSizeStore,
|
||||
} from '../usePreferredPageSize.store';
|
||||
|
||||
const STORAGE_KEY = 'test-table';
|
||||
const FULL_STORAGE_KEY = '@signoz/table-columns/test-table-preferred-page-size';
|
||||
|
||||
describe('usePreferredPageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
it('returns null when no stored value exists', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when storageKey is undefined', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(undefined));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('loads stored page size from localStorage', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '25');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBe(25);
|
||||
});
|
||||
|
||||
it('ignores invalid stored values', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, 'invalid');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('persists page size to localStorage when set', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
|
||||
act(() => {
|
||||
result.current[1](30);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBe('30');
|
||||
});
|
||||
|
||||
it('removes from localStorage when set to null', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '25');
|
||||
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
|
||||
|
||||
act(() => {
|
||||
result.current[1](null);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when storageKey is undefined and set is called', () => {
|
||||
const { result } = renderHook(() => usePreferredPageSize(undefined));
|
||||
|
||||
act(() => {
|
||||
result.current[1](30);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreferredPageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
it('returns null when no stored value exists', () => {
|
||||
expect(getPreferredPageSize(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stored value from localStorage', () => {
|
||||
localStorage.setItem(FULL_STORAGE_KEY, '42');
|
||||
expect(getPreferredPageSize(STORAGE_KEY)).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
@@ -543,3 +544,406 @@ describe('useTableParams (selective URL mode — partial config object)', () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (cleanupOnUnmount option)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('clears URL params on unmount when cleanupOnUnmount is true', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
cleanupOnUnmount: true,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set some values
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Verify values set
|
||||
expect(result.current.limit).toBe(50);
|
||||
expect(result.current.page).toBe(3);
|
||||
|
||||
// Unmount triggers cleanup
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Last URL update should have cleared params
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBeNull();
|
||||
expect(lastUpdate[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not clear URL params on unmount when cleanupOnUnmount is false', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
cleanupOnUnmount: false,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(50);
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No new URL updates after unmount (or same count)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
|
||||
});
|
||||
|
||||
it('defaults cleanupOnUnmount to false', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams({ page: 'page', limit: 'limit' }, { page: 1, limit: 10 }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.setLimit(50);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL should still have limit=50 (cleanup not triggered)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (auto page size with storageKey)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit default when no URL, no calculated, no preferred', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: null,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use explicit default (10), NOT the internal DEFAULT_LIMIT (50)
|
||||
expect(result.current.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('uses calculatedPageSize when available and no preferred', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('prefers stored value over calculatedPageSize', () => {
|
||||
// Pre-populate the store
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use preferred (25), not calculated (42)
|
||||
expect(result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('preserves URL limit over calculated and preferred', () => {
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
const wrapper = createNuqsWrapper({ limit: '30' });
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Should use URL (30), not preferred (25) or calculated (42)
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('persists user selection when different from calculated', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects 30 (different from calculated 42)
|
||||
act(() => {
|
||||
result.current.setLimit(30);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(30);
|
||||
expect(
|
||||
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
|
||||
).toBe('30');
|
||||
});
|
||||
|
||||
it('clears preference when user selects calculated value', () => {
|
||||
// Pre-set a preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'30',
|
||||
);
|
||||
usePreferredPageSizeStore.setState({ tables: { 'test-table': 30 } });
|
||||
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects 42 (same as calculated)
|
||||
act(() => {
|
||||
result.current.setLimit(42);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
// Preference should be cleared (null removes from storage)
|
||||
expect(
|
||||
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns calculated value even before URL is synced', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Limit should be 42 (calculated) even if URL sync is async
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('does not override URL when it already has a value', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Limit should stay at 30 (from URL), not change to 42
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('handles calculatedPageSize changing from null to number', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ calculated }) =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table-2',
|
||||
calculatedPageSize: calculated,
|
||||
},
|
||||
),
|
||||
{ wrapper, initialProps: { calculated: null as number | null } },
|
||||
);
|
||||
|
||||
// Initially should use explicit default (10)
|
||||
expect(result.current.limit).toBe(10);
|
||||
|
||||
// When calculated becomes available, should update
|
||||
rerender({ calculated: 42 });
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Limit should now be 42
|
||||
expect(result.current.limit).toBe(42);
|
||||
});
|
||||
|
||||
it('keeps user selection when calculatedPageSize changes', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ calculated }) =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table-3',
|
||||
calculatedPageSize: calculated,
|
||||
},
|
||||
),
|
||||
{ wrapper, initialProps: { calculated: 42 as number | null } },
|
||||
);
|
||||
|
||||
expect(result.current.limit).toBe(42);
|
||||
|
||||
// User selects 30
|
||||
act(() => {
|
||||
result.current.setLimit(30);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(30);
|
||||
|
||||
// calculatedPageSize changes (e.g., window resize)
|
||||
rerender({ calculated: 50 });
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should keep user's selection (30), not change to new calculated (50)
|
||||
expect(result.current.limit).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useQueryStates, parseAsInteger } from 'nuqs';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const QUERY_PARAMS_CONFIG = {
|
||||
orderBy: 'orderBy',
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
} as const;
|
||||
|
||||
type TableParamsWithCleanup = ReturnType<typeof useTableParams> & {
|
||||
clearParams: ReturnType<typeof useQueryStates>[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulates the cleanup pattern used in ListAlertRules:
|
||||
* - Uses useQueryStates to clear URL params on unmount
|
||||
*/
|
||||
function useTableParamsWithCleanup(
|
||||
storageKey: string,
|
||||
calculatedPageSize: number | null,
|
||||
): TableParamsWithCleanup {
|
||||
const result = useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey,
|
||||
calculatedPageSize,
|
||||
});
|
||||
|
||||
// This mirrors the cleanup effect in ListAlertRules
|
||||
const [, setTableQueryParams] = useQueryStates({
|
||||
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
|
||||
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
|
||||
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
|
||||
});
|
||||
|
||||
// Note: We can't use useEffect cleanup in tests easily, but we can verify
|
||||
// that calling setTableQueryParams with nulls does clear the URL
|
||||
|
||||
return { ...result, clearParams: setTableQueryParams };
|
||||
}
|
||||
|
||||
describe('URL cleanup pattern (simulating ListAlertRules behavior)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('setTableQueryParams with null values should clear URL params', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTableParamsWithCleanup('alert-rules', 42),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set limit to 100
|
||||
await act(async () => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Verify limit=100 is in URL
|
||||
const limitAfterSet = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(limitAfterSet).toBe('100');
|
||||
|
||||
// Simulate cleanup: clear all params
|
||||
await act(async () => {
|
||||
void result.current.clearParams({
|
||||
orderBy: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
});
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Verify limit was cleared (last update should have limit=null or removed)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('cleanup should work even when limit was set from localStorage preference', async () => {
|
||||
// Pre-set preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTableParamsWithCleanup('alert-rules', 42),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should use preferred value
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Simulate cleanup
|
||||
await act(async () => {
|
||||
void result.current.clearParams({
|
||||
orderBy: null,
|
||||
page: null,
|
||||
limit: null,
|
||||
});
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// URL should be cleared
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBeNull();
|
||||
});
|
||||
|
||||
it('demonstrates the bug: component without cleanup leaves limit in URL', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount TriggeredAlerts-like component (no cleanup)
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(QUERY_PARAMS_CONFIG, {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'triggered-alerts',
|
||||
calculatedPageSize: 42,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set limit to 100
|
||||
await act(async () => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Unmount WITHOUT cleanup
|
||||
unmount();
|
||||
|
||||
// Verify limit=100 is STILL in URL (this is the bug!)
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
const finalLimit = lastUpdate[0].searchParams.get('limit');
|
||||
expect(finalLimit).toBe('100'); // BUG: limit persists after unmount
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useTableParams navigation scenarios', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
usePreferredPageSizeStore.setState({ tables: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Tab navigation: Alert Rules -> Configuration -> Routing Policies', () => {
|
||||
it('preferred value from one table should NOT leak to URL when navigating away', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Simulate Alert Rules: user sets limit=100
|
||||
const alertRules = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'alert-rules',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// User selects limit=100
|
||||
act(() => {
|
||||
alertRules.result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(alertRules.result.current.limit).toBe(100);
|
||||
|
||||
// Verify it's persisted in localStorage
|
||||
expect(
|
||||
localStorage.getItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
),
|
||||
).toBe('100');
|
||||
|
||||
// Simulate unmount (user navigates away)
|
||||
alertRules.unmount();
|
||||
|
||||
// At this point, the URL should NOT have limit=100 from alert-rules
|
||||
// when another component mounts with a different storageKey
|
||||
});
|
||||
|
||||
it('different tables with different storageKeys maintain separate preferences', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Alert Rules sets limit=100
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
// Triggered Alerts sets limit=25
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/triggered-alerts-preferred-page-size',
|
||||
'25',
|
||||
);
|
||||
|
||||
// Mount Triggered Alerts (simulating tab switch from Alert Rules)
|
||||
const triggeredAlerts = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'triggered-alerts',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use triggered-alerts preference (25), NOT alert-rules (100)
|
||||
expect(triggeredAlerts.result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('table without storageKey should NOT write preference to URL from another table', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
// Pre-set alert-rules preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/alert-rules-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
// Start fresh with NO URL params
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount a table WITHOUT storageKey (simulating a simple table)
|
||||
const simpleTable = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
// NO storageKey
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use calculated (42), not alert-rules preference (100)
|
||||
expect(simpleTable.result.current.limit).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL cleanup on unmount', () => {
|
||||
it('URL params should be cleanable by consumer on unmount', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-cleanup',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Set some values
|
||||
act(() => {
|
||||
result.current.setLimit(50);
|
||||
result.current.setPage(3);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Verify URL was updated
|
||||
const limitUpdates = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter(Boolean);
|
||||
expect(limitUpdates).toContain('50');
|
||||
|
||||
// Unmount (note: useTableParams itself doesn't cleanup URL - consumer should)
|
||||
unmount();
|
||||
|
||||
// Verify the component unmounted (no errors)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parallel tables sharing URL params', () => {
|
||||
it('two tables using same URL params should see same values when URL pre-set', () => {
|
||||
const wrapper = createNuqsWrapper({ limit: '30' });
|
||||
|
||||
const table1 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const table2 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 20,
|
||||
storageKey: 'table-2',
|
||||
calculatedPageSize: 50,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Both should see URL value (30), not their defaults
|
||||
expect(table1.result.current.limit).toBe(30);
|
||||
expect(table2.result.current.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('table mounted after setLimit should see updated URL value', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
|
||||
// Table1 mounts first
|
||||
const table1 = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(table1.result.current.limit).toBe(42);
|
||||
|
||||
// Table1 sets limit to 100
|
||||
act(() => {
|
||||
table1.result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(table1.result.current.limit).toBe(100);
|
||||
|
||||
// Table2 mounts AFTER table1 set limit=100 in URL
|
||||
// In test environment, URL state doesn't persist between renderHook calls
|
||||
// This test documents current behavior - each hook instance is independent
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL state initialization race conditions', () => {
|
||||
it('should not write preferred value to URL if URL already has value', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
// Pre-set preference
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table-preferred-page-size',
|
||||
'100',
|
||||
);
|
||||
|
||||
// URL already has limit=30
|
||||
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'test-table',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Should use URL (30), not preferred (100)
|
||||
expect(result.current.limit).toBe(30);
|
||||
|
||||
// URL should NOT have been overwritten with 100
|
||||
const limitUpdates = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('limit'))
|
||||
.filter((v) => v === '100');
|
||||
expect(limitUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('URL init effect should write calculated value when URL empty', async () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
// Mount with no URL params
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Effects run after render, need to flush
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Should use calculated value
|
||||
expect(result.current.limit).toBe(42);
|
||||
|
||||
// The URL init effect writes to URL asynchronously
|
||||
// Check that limit is 42 (which it is from the limitDefault calculation)
|
||||
});
|
||||
|
||||
it('consumer cleanup effect is responsible for clearing URL params', () => {
|
||||
// This test documents that useTableParams does NOT auto-cleanup URL
|
||||
// Consumer components (like ListAlertRules) must use useEffect cleanup
|
||||
// to clear URL params when unmounting
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() =>
|
||||
useTableParams(
|
||||
{ page: 'page', limit: 'limit' },
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
storageKey: 'table-1',
|
||||
calculatedPageSize: 42,
|
||||
},
|
||||
),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setLimit(100);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.limit).toBe(100);
|
||||
|
||||
// Unmount - useTableParams does NOT clear URL
|
||||
unmount();
|
||||
|
||||
// Verify unmount happened without clearing URL
|
||||
// The last URL update should still have limit=100, not null
|
||||
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
|
||||
expect(lastUpdate[0].searchParams.get('limit')).toBe('100');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -192,6 +194,67 @@ export * from './useTableParams';
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example useTableParams — manages pagination state with URL sync and persistence
|
||||
*
|
||||
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync
|
||||
* to URL params, persist user's page size preference, and auto-calculate page size from
|
||||
* container height.
|
||||
*
|
||||
* **Priority chain for limit**: URL > preferred (localStorage) > calculated > explicit default > 50
|
||||
*
|
||||
* ```tsx
|
||||
* import { useCalculatedPageSize, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const QUERY_PARAMS = { page: 'page', limit: 'limit', orderBy: 'orderBy' } as const;
|
||||
*
|
||||
* function MyTable({ data, columns }) {
|
||||
* // Auto-calculate page size based on container height
|
||||
* const { containerRef, calculatedPageSize } = useCalculatedPageSize({ rowHeight: 42 });
|
||||
*
|
||||
* // useTableParams options:
|
||||
* // - storageKey: persists user's page size selection to localStorage
|
||||
* // - calculatedPageSize: uses this when no URL/preferred value exists
|
||||
* // - cleanupOnUnmount: clears URL params when component unmounts
|
||||
* const { page, limit, setLimit, orderBy } = useTableParams(QUERY_PARAMS, {
|
||||
* page: 1,
|
||||
* limit: 10,
|
||||
* storageKey: 'my-table',
|
||||
* calculatedPageSize,
|
||||
* cleanupOnUnmount: true,
|
||||
* });
|
||||
*
|
||||
* const paginatedData = useMemo(() => {
|
||||
* const start = (page - 1) * limit;
|
||||
* return data.slice(start, start + limit);
|
||||
* }, [data, page, limit]);
|
||||
*
|
||||
* return (
|
||||
* <div ref={containerRef} style={{ height: '100%' }}>
|
||||
* <TanStackTable
|
||||
* data={paginatedData}
|
||||
* columns={columns}
|
||||
* enableQueryParams={QUERY_PARAMS}
|
||||
* pagination={{
|
||||
* total: data.length,
|
||||
* calculatedPageSize,
|
||||
* onLimitChange: setLimit,
|
||||
* }}
|
||||
* />
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **useTableParams options:**
|
||||
* - `storageKey`: Persists user's page size to localStorage. When user selects a size
|
||||
* different from calculated, it's saved. Selecting calculated size clears preference.
|
||||
* - `calculatedPageSize`: From `useCalculatedPageSize`. Used as default when no URL/preferred.
|
||||
* - `cleanupOnUnmount`: Clears URL params (page, limit, orderBy, expanded) on unmount.
|
||||
* Use when navigating away should reset table state.
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -74,6 +74,7 @@ export type TableColumnDef<
|
||||
min?: number | string;
|
||||
default?: number | string;
|
||||
max?: number | string;
|
||||
ignoreLastColumnFill?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,6 +112,14 @@ export type TableRowContext<TData> = {
|
||||
enableAlternatingRowColors?: boolean;
|
||||
};
|
||||
|
||||
export type AutoPageSizeConfig = {
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
paginationHeight?: number;
|
||||
minPageSize?: number;
|
||||
maxPageSize?: number;
|
||||
};
|
||||
|
||||
export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
@@ -123,6 +132,12 @@ export type PaginationProps = {
|
||||
onLimitChange?: (limit: number) => void;
|
||||
showTotalCount?: boolean;
|
||||
totalCountLabel?: string;
|
||||
/**
|
||||
* Auto-calculated page size for the current container.
|
||||
* When set, shows as "Auto (N)" option in the page size dropdown.
|
||||
* Consumer is responsible for calculating this via useCalculatedPageSize.
|
||||
*/
|
||||
calculatedPageSize?: number | null;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AutoPageSizeConfig } from './types';
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = 36;
|
||||
const DEFAULT_HEADER_HEIGHT = 36;
|
||||
const DEFAULT_PAGINATION_HEIGHT = 62;
|
||||
const MIN_PAGE_SIZE = 5;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export type UseCalculatedPageSizeResult = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
calculatedPageSize: number | null;
|
||||
};
|
||||
|
||||
export function useCalculatedPageSize(
|
||||
config?: AutoPageSizeConfig,
|
||||
): UseCalculatedPageSizeResult {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [calculatedPageSize, setCalculatedPageSize] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const rowHeight = config?.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
||||
const headerHeight = config?.headerHeight ?? DEFAULT_HEADER_HEIGHT;
|
||||
const paginationHeight = config?.paginationHeight ?? DEFAULT_PAGINATION_HEIGHT;
|
||||
const minPageSize = config?.minPageSize ?? MIN_PAGE_SIZE;
|
||||
const maxPageSize = config?.maxPageSize ?? MAX_PAGE_SIZE;
|
||||
|
||||
const calculatePageSize = useCallback(
|
||||
(containerHeight: number): number => {
|
||||
const availableHeight = containerHeight - headerHeight - paginationHeight;
|
||||
const rawPageSize = Math.floor(availableHeight / rowHeight);
|
||||
return Math.min(maxPageSize, Math.max(minPageSize, rawPageSize));
|
||||
},
|
||||
[rowHeight, headerHeight, paginationHeight, minPageSize, maxPageSize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height } = entry.contentRect;
|
||||
if (height > 0) {
|
||||
const newPageSize = calculatePageSize(height);
|
||||
setCalculatedPageSize((prev) =>
|
||||
prev !== newPageSize ? newPageSize : prev,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
const { height } = container.getBoundingClientRect();
|
||||
if (height > 0) {
|
||||
setCalculatedPageSize(calculatePageSize(height));
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculatePageSize]);
|
||||
|
||||
return { containerRef, calculatedPageSize };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const STORAGE_PREFIX = '@signoz/table-columns/';
|
||||
const STORAGE_SUFFIX = '-preferred-page-size';
|
||||
|
||||
type PreferredPageSizeState = {
|
||||
tables: Record<string, number | null>;
|
||||
setPreferredPageSize: (storageKey: string, pageSize: number | null) => void;
|
||||
};
|
||||
|
||||
const getStorageKey = (tableKey: string): string =>
|
||||
`${STORAGE_PREFIX}${tableKey}${STORAGE_SUFFIX}`;
|
||||
|
||||
const loadFromStorage = (tableKey: string): number | null => {
|
||||
try {
|
||||
const raw = get(getStorageKey(tableKey));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveToStorage = (tableKey: string, pageSize: number | null): void => {
|
||||
try {
|
||||
const key = getStorageKey(tableKey);
|
||||
if (pageSize === null) {
|
||||
remove(key);
|
||||
} else {
|
||||
set(key, String(pageSize));
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const usePreferredPageSizeStore = create<PreferredPageSizeState>()(
|
||||
(set, get) => ({
|
||||
tables: {},
|
||||
setPreferredPageSize: (storageKey, pageSize): void => {
|
||||
set({ tables: { ...get().tables, [storageKey]: pageSize } });
|
||||
saveToStorage(storageKey, pageSize);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export function usePreferredPageSize(
|
||||
storageKey: string | undefined,
|
||||
): [number | null, (pageSize: number | null) => void] {
|
||||
const pageSize = usePreferredPageSizeStore((s) => {
|
||||
if (!storageKey) {
|
||||
return null;
|
||||
}
|
||||
const cached = s.tables[storageKey];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
return loadFromStorage(storageKey);
|
||||
});
|
||||
|
||||
const setPageSize = usePreferredPageSizeStore((s) => s.setPreferredPageSize);
|
||||
|
||||
const setPreferred = (size: number | null): void => {
|
||||
if (storageKey) {
|
||||
setPageSize(storageKey, size);
|
||||
}
|
||||
};
|
||||
|
||||
return [pageSize, setPreferred];
|
||||
}
|
||||
|
||||
export function getPreferredPageSize(storageKey: string): number | null {
|
||||
// oxlint-disable-next-line signoz/no-zustand-getstate-in-hooks
|
||||
const state = usePreferredPageSizeStore.getState();
|
||||
const cached = state.tables[storageKey];
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stored = loadFromStorage(storageKey);
|
||||
if (stored !== null) {
|
||||
state.setPreferredPageSize(storageKey, stored);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { SortState, TanstackTableQueryParamsConfig } from './types';
|
||||
import { usePreferredPageSize } from './usePreferredPageSize.store';
|
||||
|
||||
const NUQS_OPTIONS = { history: 'push' as const };
|
||||
const DEFAULT_PAGE = 1;
|
||||
@@ -20,9 +21,15 @@ type Defaults = {
|
||||
limit?: number;
|
||||
orderBy?: SortState | null;
|
||||
expanded?: ExpandedState;
|
||||
/** Storage key for persisting user's page size preference */
|
||||
storageKey?: string;
|
||||
/** Auto-calculated page size from container. URL initializes with this when available. */
|
||||
calculatedPageSize?: number | null;
|
||||
/** Clear URL params on unmount. Useful when navigating away from table views. */
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
type TableParamsResult = {
|
||||
export type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
@@ -99,15 +106,23 @@ export function useTableParams(
|
||||
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
|
||||
: URL_KEYS_DEFAULT.expanded;
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
const expandedDefault = defaults?.expanded ?? {};
|
||||
const storageKey = defaults?.storageKey;
|
||||
const calculatedPageSize = defaults?.calculatedPageSize;
|
||||
const cleanupOnUnmount = defaults?.cleanupOnUnmount ?? false;
|
||||
const expandedDefaultArray = useMemo(
|
||||
() => expandedStateToArray(expandedDefault),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const [preferredPageSize, setPreferredPageSize] =
|
||||
usePreferredPageSize(storageKey);
|
||||
|
||||
const limitDefault =
|
||||
preferredPageSize ?? calculatedPageSize ?? defaults?.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const [localPage, setLocalPage] = useState(pageDefault);
|
||||
const [localLimit, setLocalLimit] = useState(limitDefault);
|
||||
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
||||
@@ -120,9 +135,71 @@ export function useTableParams(
|
||||
pageQueryParam,
|
||||
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlLimit, setUrlLimit] = useQueryState(
|
||||
const [urlLimitRaw, setUrlLimitRaw] = useQueryState(
|
||||
limitQueryParam,
|
||||
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
||||
parseAsInteger.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
|
||||
// Track if URL had limit on initial mount
|
||||
const hadUrlLimitOnMountRef = useRef<boolean | null>(null);
|
||||
if (hadUrlLimitOnMountRef.current === null) {
|
||||
hadUrlLimitOnMountRef.current = urlLimitRaw !== null;
|
||||
}
|
||||
const hadUrlLimit = hadUrlLimitOnMountRef.current ?? false;
|
||||
|
||||
const urlLimit = urlLimitRaw ?? limitDefault;
|
||||
|
||||
// Initialize URL with preferred/calculated when available (only if URL was empty)
|
||||
const hasInitializedUrlRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!useUrlForLimit || hasInitializedUrlRef.current || hadUrlLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferredPageSize !== null) {
|
||||
hasInitializedUrlRef.current = true;
|
||||
void setUrlLimitRaw(preferredPageSize);
|
||||
return;
|
||||
}
|
||||
if (calculatedPageSize != null) {
|
||||
hasInitializedUrlRef.current = true;
|
||||
void setUrlLimitRaw(calculatedPageSize);
|
||||
}
|
||||
}, [
|
||||
useUrlForLimit,
|
||||
calculatedPageSize,
|
||||
preferredPageSize,
|
||||
hadUrlLimit,
|
||||
setUrlLimitRaw,
|
||||
]);
|
||||
|
||||
// Wrapped setLimit that persists preference when different from calculated
|
||||
const setUrlLimit = useCallback(
|
||||
(newLimit: number): void => {
|
||||
if (storageKey) {
|
||||
if (newLimit !== calculatedPageSize) {
|
||||
setPreferredPageSize(newLimit);
|
||||
} else {
|
||||
setPreferredPageSize(null);
|
||||
}
|
||||
}
|
||||
void setUrlLimitRaw(newLimit);
|
||||
},
|
||||
[storageKey, calculatedPageSize, setPreferredPageSize, setUrlLimitRaw],
|
||||
);
|
||||
|
||||
const setLocalLimitWithPersist = useCallback(
|
||||
(newLimit: number): void => {
|
||||
if (storageKey) {
|
||||
if (newLimit !== calculatedPageSize) {
|
||||
setPreferredPageSize(newLimit);
|
||||
} else {
|
||||
setPreferredPageSize(null);
|
||||
}
|
||||
}
|
||||
setLocalLimit(newLimit);
|
||||
},
|
||||
[storageKey, calculatedPageSize, setPreferredPageSize],
|
||||
);
|
||||
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
||||
orderByQueryParam,
|
||||
@@ -155,7 +232,7 @@ export function useTableParams(
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(urlExpandedRef.current)
|
||||
: updaterOrValue;
|
||||
setUrlExpandedArray(expandedStateToArray(newState));
|
||||
void setUrlExpandedArray(expandedStateToArray(newState));
|
||||
},
|
||||
[setUrlExpandedArray],
|
||||
);
|
||||
@@ -172,21 +249,53 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (useUrlForPage) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
// Only reset page when orderBy actually changes, not on initial mount
|
||||
if (
|
||||
prevOrderByRef.current !== null &&
|
||||
prevOrderByRef.current !== orderByUrlMemoKey
|
||||
) {
|
||||
if (useUrlForPage) {
|
||||
void setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}
|
||||
prevOrderByRef.current = orderByUrlMemoKey;
|
||||
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cleanupOnUnmount) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
if (useUrlForPage) {
|
||||
void setUrlPage(null);
|
||||
}
|
||||
if (useUrlForLimit) {
|
||||
void setUrlLimitRaw(null);
|
||||
}
|
||||
if (useUrlForOrderBy) {
|
||||
void setUrlOrderBy(null);
|
||||
}
|
||||
if (useUrlForExpanded) {
|
||||
void setUrlExpandedArray(null);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
cleanupOnUnmount,
|
||||
useUrlForPage,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
useUrlForLimit,
|
||||
useUrlForOrderBy,
|
||||
useUrlForExpanded,
|
||||
setUrlPage,
|
||||
setUrlLimitRaw,
|
||||
setUrlOrderBy,
|
||||
setUrlExpandedArray,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -195,7 +304,7 @@ export function useTableParams(
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { RowKeyData, TableColumnDef } from './types';
|
||||
import { ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
|
||||
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
||||
column.id;
|
||||
@@ -34,7 +35,7 @@ export const getColumnWidthStyle = <TData>(
|
||||
isLastColumn?: boolean,
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn) {
|
||||
if (isLastColumn && column?.width?.ignoreLastColumnFill !== true) {
|
||||
return {
|
||||
width: '100%',
|
||||
minWidth: persistedWidth ?? column?.width?.min,
|
||||
@@ -145,3 +146,31 @@ export function buildTanstackColumnDef<TData>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZES = [10, 20, 30, 50, 100];
|
||||
|
||||
export function buildPageSizeItems(
|
||||
calculatedSize?: number | null,
|
||||
): ComboboxSimpleItem[] {
|
||||
const items: ComboboxSimpleItem[] = [];
|
||||
|
||||
if (calculatedSize) {
|
||||
items.push({
|
||||
value: calculatedSize.toString(),
|
||||
label: `Auto (${calculatedSize})`,
|
||||
displayValue: calculatedSize.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const size of DEFAULT_PAGE_SIZES) {
|
||||
if (size !== calculatedSize) {
|
||||
items.push({
|
||||
value: size.toString(),
|
||||
label: size.toString(),
|
||||
displayValue: size.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, Globe } from '@signozhq/icons';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TimeItems, {
|
||||
timePreferance,
|
||||
@@ -27,20 +28,17 @@ function TimePreference({
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: menuItems,
|
||||
onClick: timeMenuItemOnChangeHandler,
|
||||
items: menuItems.map((item) => ({
|
||||
...item,
|
||||
onClick: timeMenuItemOnChangeHandler,
|
||||
})),
|
||||
}),
|
||||
[timeMenuItemOnChangeHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
rootClassName="time-selection-menu"
|
||||
className="time-selection-target"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button>
|
||||
<DropdownMenuSimple menu={menu} className="time-selection-menu">
|
||||
<Button className="time-selection-target">
|
||||
<div className="button-selected-text">
|
||||
<Globe size={14} />
|
||||
<Typography.Text className="selected-value">
|
||||
@@ -49,7 +47,7 @@ function TimePreference({
|
||||
</div>
|
||||
<ChevronDown size="md" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -9,7 +10,17 @@ import {
|
||||
CommandShortcut,
|
||||
} from '@signozhq/ui/command';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
AIAssistantOpenSource,
|
||||
} from 'container/AIAssistant/events';
|
||||
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
|
||||
import {
|
||||
openAIAssistantModal,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
@@ -37,6 +48,11 @@ export function CmdKPalette({
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
const location = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
// toggle palette with ⌘/Ctrl+K
|
||||
function handleGlobalCmdK(
|
||||
@@ -78,9 +94,21 @@ export function CmdKPalette({
|
||||
history.push(key);
|
||||
}
|
||||
|
||||
const handleOpenAIAssistant = (): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: AIAssistantOpenSource.Cmdk,
|
||||
currentPage: normalizePage(location.pathname),
|
||||
});
|
||||
startNewConversation();
|
||||
openAIAssistantModal();
|
||||
};
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: onClickHandler,
|
||||
handleThemeChange,
|
||||
aiAssistant: isAIAssistantEnabled
|
||||
? { open: handleOpenAIAssistant }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
|
||||
@@ -88,6 +88,7 @@ const ROUTES = {
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
AI_ASSISTANT: '/ai-assistant/:conversationId',
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
} as const;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Sparkles,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
@@ -34,12 +35,20 @@ export type CmdAction = {
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
/**
|
||||
* Provided only when the AI Assistant feature is available for the current
|
||||
* tenant. When present, the palette surfaces an "Open AI Assistant" entry
|
||||
* at the top; when absent, the action is omitted entirely.
|
||||
*/
|
||||
aiAssistant?: {
|
||||
open: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange } = deps;
|
||||
const { navigate, handleThemeChange, aiAssistant } = deps;
|
||||
|
||||
return [
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
@@ -279,4 +288,19 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
|
||||
},
|
||||
];
|
||||
|
||||
if (aiAssistant) {
|
||||
actions.unshift({
|
||||
id: 'ai-assistant',
|
||||
name: 'Open AI Assistant',
|
||||
shortcut: ['cmd+j'],
|
||||
keywords: 'ai assistant chat ask sparkles copilot',
|
||||
section: 'AI Assistant',
|
||||
icon: <Sparkles size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: aiAssistant.open,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import logEvent from 'api/common/logEvent';
|
||||
|
||||
import HistorySidebar from '../components/ConversationsList';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { AIAssistantEvents } from '../events';
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import {
|
||||
normalizePage,
|
||||
useAIAssistantAnalyticsContext,
|
||||
@@ -65,7 +65,7 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: 'shortcut',
|
||||
source: AIAssistantOpenSource.Shortcut,
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openModal();
|
||||
@@ -162,57 +162,57 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle conversations"
|
||||
className={showHistory ? styles.toggleBtnActive : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
prefix={<History size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
prefix={<Plus size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
prefix={<Maximize2 size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
prefix={<Minus size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
prefix={<X size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,9 +150,8 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
color="secondary"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle conversations"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
prefix={<History size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="New conversation">
|
||||
@@ -162,9 +161,8 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
color="secondary"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
prefix={<Plus size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Open full screen">
|
||||
@@ -175,9 +173,8 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
prefix={<Maximize2 size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Close">
|
||||
@@ -187,9 +184,8 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
prefix={<X size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Bot } from '@signozhq/icons';
|
||||
|
||||
import { AIAssistantEvents } from '../events';
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
import {
|
||||
openAIAssistant,
|
||||
@@ -31,7 +31,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
|
||||
const handleOpen = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: 'icon',
|
||||
source: AIAssistantOpenSource.Icon,
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openAIAssistant();
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function ConversationView({
|
||||
<ConversationSkeleton />
|
||||
<div className={inputWrapperClass}>
|
||||
<ChatInput
|
||||
key={conversationId}
|
||||
onSend={handleSend}
|
||||
disabled
|
||||
autoContexts={autoContexts}
|
||||
@@ -172,6 +173,7 @@ export default function ConversationView({
|
||||
return (
|
||||
<div className={styles.conversation}>
|
||||
<VirtualizedMessages
|
||||
key={conversationId}
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
@@ -184,6 +186,7 @@ export default function ConversationView({
|
||||
)}
|
||||
<div className={inputWrapperClass}>
|
||||
<ChatInput
|
||||
key={conversationId}
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
disabled={inputDisabled}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
DialogSubtitle,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ApprovalEventDTODiff,
|
||||
@@ -100,16 +101,16 @@ export default function ApprovalCard({
|
||||
<div className={styles.diffSection}>
|
||||
<div className={styles.diffHeader}>
|
||||
<span className={styles.diffHeaderLabel}>Diff</span>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={(): void => setDiffExpanded(true)}
|
||||
title="Expand diff"
|
||||
aria-label="Expand diff"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
<TooltipSimple title="Expand diff">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={(): void => setDiffExpanded(true)}
|
||||
aria-label="Expand diff"
|
||||
prefix={<Maximize2 size={12} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
<DiffView diff={approval.diff} />
|
||||
</div>
|
||||
@@ -119,6 +120,8 @@ export default function ApprovalCard({
|
||||
<DialogContent
|
||||
className={styles.diffDialog}
|
||||
style={{ width: '80vw', maxWidth: '80vw', height: '70vh' }}
|
||||
// Skip auto-focus — otherwise the first Copy button opens its tooltip on dialog open.
|
||||
onOpenAutoFocus={(e): void => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approval diff</DialogTitle>
|
||||
@@ -129,38 +132,43 @@ export default function ApprovalCard({
|
||||
<div className={styles.diffModalBody}>
|
||||
<p className={styles.diffModalSummary}>{approval.summary}</p>
|
||||
<div className={styles.diffToolbarRow}>
|
||||
<ToggleGroup
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
size="sm"
|
||||
value={viewMode}
|
||||
onChange={(next): void => {
|
||||
onChange={(next: string): void => {
|
||||
// Radix `single` group can emit '' when the active item
|
||||
// is clicked again — preserve the current mode.
|
||||
if (next === 'split' || next === 'unified') {
|
||||
setViewMode(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="split" aria-label="Split view">
|
||||
<Columns2 size={12} />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="unified" aria-label="Unified view">
|
||||
<List size={12} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
items={[
|
||||
{
|
||||
value: 'split',
|
||||
'aria-label': 'Split view',
|
||||
label: <Columns2 size={12} />,
|
||||
},
|
||||
{
|
||||
value: 'unified',
|
||||
'aria-label': 'Unified view',
|
||||
label: <List size={12} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ToggleGroupSimple
|
||||
type="multiple"
|
||||
size="sm"
|
||||
value={wrapText ? ['wrap'] : []}
|
||||
onChange={(next): void => setWrapText(next.includes('wrap'))}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="wrap"
|
||||
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
|
||||
>
|
||||
<WrapText size={12} />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
onChange={(next: string[]): void => setWrapText(next.includes('wrap'))}
|
||||
items={[
|
||||
{
|
||||
value: 'wrap',
|
||||
'aria-label': wrapText ? 'Disable text wrap' : 'Wrap long lines',
|
||||
label: <WrapText size={12} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{approval.diff && (
|
||||
<DiffView
|
||||
@@ -457,15 +465,16 @@ function CopyButton({ text, label }: CopyButtonProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopy}
|
||||
title={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
<TooltipSimple title={copied ? `Copied ${label}` : `Copy ${label}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,7 @@
|
||||
border-radius: var(--radius-2);
|
||||
padding: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
transition: border-color 0.15s;
|
||||
position: relative;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
@@ -129,6 +124,18 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 4px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
// Scope the focus ring to the textarea row only — the surrounding
|
||||
// chrome (context chips, "Add Context", mic, send) sits outside this
|
||||
// element and stays unaffected when the cursor enters the textarea.
|
||||
&:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px
|
||||
color-mix(in srgb, var(--accent-primary), transparent 70%);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -244,16 +251,24 @@
|
||||
}
|
||||
|
||||
.contextPopoverCategoryItem {
|
||||
// Override DS Button's centered layout.
|
||||
--button-justify-content: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
|
||||
border-radius: var(--radius-2);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
text-align: left;
|
||||
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
|
||||
border-radius: var(--radius-2);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
@@ -309,17 +324,24 @@
|
||||
}
|
||||
|
||||
.contextPopoverEntityItem {
|
||||
// Override DS Button's centered layout.
|
||||
--button-justify-content: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
|
||||
border-radius: var(--radius-2);
|
||||
background: transparent;
|
||||
color: var(--l1-foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
|
||||
border-radius: var(--radius-2);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
// Required for the inner span's `text-overflow: ellipsis` to engage —
|
||||
// flex items default to `min-width: auto` (intrinsic width) and would
|
||||
@@ -385,6 +407,11 @@
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
// Reset native <button> defaults so the 24px circle isn't inflated by
|
||||
// browser-default padding / font metrics.
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.micDiscard {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import cx from 'classnames';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
@@ -26,7 +32,11 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { AIAssistantEvents, getBrowserInfo } from '../../events';
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
VoiceInputSource,
|
||||
getBrowserInfo,
|
||||
} from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../../types';
|
||||
@@ -142,6 +152,10 @@ function autoContextCategory(ctx: MessageContext): string {
|
||||
|
||||
const MAX_INPUT_LENGTH = 20000;
|
||||
const WARNING_THRESHOLD = 15000;
|
||||
// Cap for the auto-growing composer. Past this, the textarea stops growing
|
||||
// and starts scrolling internally so the message list above doesn't get
|
||||
// squeezed in tighter container variants (e.g. the floating panel).
|
||||
const TEXTAREA_MAX_HEIGHT_PX = 200;
|
||||
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';
|
||||
@@ -224,6 +238,18 @@ export default function ChatInput({
|
||||
const [activeContextCategory, setActiveContextCategory] =
|
||||
useState<ContextCategory>('Dashboards');
|
||||
const [pickerSearchQuery, setPickerSearchQuery] = useState('');
|
||||
// Refs to each category tab so we can move DOM focus to the newly-active
|
||||
// tab on ArrowUp/ArrowDown. Without this the roving-tabindex pattern
|
||||
// stalls: focus stays on the original button (whose closure has the old
|
||||
// category), so subsequent arrow keys never advance past the second tab.
|
||||
const categoryTabRefs = useRef(
|
||||
new Map<ContextCategory, HTMLButtonElement | null>(),
|
||||
);
|
||||
// Refs to each entity row in the active tab panel, so we can cross from
|
||||
// the category tablist (ArrowRight) into the panel and step through
|
||||
// entities with ArrowUp/Down. Array is rewritten each render — there's
|
||||
// only ever one tab panel mounted so stale indices clear naturally.
|
||||
const entityRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// When the picker was opened by typing `@` in the textarea, this holds the
|
||||
@@ -303,11 +329,92 @@ export default function ChatInput({
|
||||
[mentionRange, selectedContexts, text],
|
||||
);
|
||||
|
||||
const focusCategory = useCallback((category: ContextCategory) => {
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
categoryTabRefs.current.get(category)?.focus();
|
||||
}, []);
|
||||
|
||||
const handleCategoryKeyDown = useCallback(
|
||||
(
|
||||
e: React.KeyboardEvent<HTMLButtonElement>,
|
||||
category: ContextCategory,
|
||||
): void => {
|
||||
const total = CONTEXT_CATEGORIES.length;
|
||||
const idx = CONTEXT_CATEGORIES.indexOf(category);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
focusCategory(CONTEXT_CATEGORIES[(idx + 1) % total]);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
focusCategory(CONTEXT_CATEGORIES[(idx - 1 + total) % total]);
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
focusCategory(CONTEXT_CATEGORIES[0]);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
focusCategory(CONTEXT_CATEGORIES[total - 1]);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// Cross from tablist into entity panel.
|
||||
e.preventDefault();
|
||||
entityRefs.current[0]?.focus();
|
||||
}
|
||||
},
|
||||
[focusCategory],
|
||||
);
|
||||
|
||||
const handleEntityKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
||||
const count = entityRefs.current.length;
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
const focusAt = (i: number): void => {
|
||||
e.preventDefault();
|
||||
entityRefs.current[i]?.focus();
|
||||
};
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
focusAt((index + 1) % count);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
focusAt((index - 1 + count) % count);
|
||||
break;
|
||||
case 'Home':
|
||||
focusAt(0);
|
||||
break;
|
||||
case 'End':
|
||||
focusAt(count - 1);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
// Cross back to tablist.
|
||||
e.preventDefault();
|
||||
categoryTabRefs.current.get(activeContextCategory)?.focus();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
[activeContextCategory],
|
||||
);
|
||||
|
||||
// Focus the textarea when this component mounts (panel/modal open)
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Auto-grow the textarea so long prompts aren't trapped in a 2-line
|
||||
// scrolling porthole. Reset to `auto` first to let the field shrink back
|
||||
// down when the user deletes content, then snap to scrollHeight capped at
|
||||
// TEXTAREA_MAX_HEIGHT_PX (overflow-y: auto in CSS handles the rest).
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX)}px`;
|
||||
}, [text]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed && pendingFiles.length === 0) {
|
||||
@@ -382,7 +489,7 @@ export default function ChatInput({
|
||||
// start time so we can attribute `durationMs` on the Voice input used
|
||||
// event regardless of which control ended the session.
|
||||
const voiceStartedAtRef = useRef<number | null>(null);
|
||||
const voiceSourceRef = useRef<'button' | 'shortcut' | null>(null);
|
||||
const voiceSourceRef = useRef<VoiceInputSource | null>(null);
|
||||
// Set to true after a `network`, `not-allowed`, or `not-supported` failure
|
||||
// so we hide the mic button for the rest of the tab session — silent
|
||||
// retries don't help, and Chromium derivatives without the Google Speech
|
||||
@@ -459,7 +566,7 @@ export default function ChatInput({
|
||||
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
|
||||
|
||||
const startVoiceInput = useCallback(
|
||||
(source: 'button' | 'shortcut') => {
|
||||
(source: VoiceInputSource) => {
|
||||
// Defense in depth: the button is hidden when `voiceUnavailable` is
|
||||
// true, but the PTT shortcut listener can still call us. Bailing here
|
||||
// keeps a single source of truth and prevents repeat `Voice input
|
||||
@@ -536,7 +643,7 @@ export default function ChatInput({
|
||||
return; // ignore auto-repeat
|
||||
}
|
||||
pttActiveRef.current = true;
|
||||
startVoiceInput('shortcut');
|
||||
startVoiceInput(VoiceInputSource.Shortcut);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent): void => {
|
||||
@@ -724,6 +831,12 @@ export default function ChatInput({
|
||||
entity.value.toLowerCase().includes(activeQuery),
|
||||
)
|
||||
: contextEntitiesByCategory[activeContextCategory];
|
||||
// Truncate the ref array to match the current entity count so that
|
||||
// switching from a large category (e.g. 100 dashboards) to a smaller one
|
||||
// doesn't leave stale `null` slots from earlier renders. Keyboard nav math
|
||||
// already uses `filteredContextOptions.length` for the modulo, so stale
|
||||
// slots wouldn't be reached — this is purely housekeeping.
|
||||
entityRefs.current.length = filteredContextOptions.length;
|
||||
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
|
||||
contextCategoryStateByCategory[activeContextCategory];
|
||||
const currentLength = text.length;
|
||||
@@ -830,7 +943,7 @@ export default function ChatInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
maxLength={MAX_INPUT_LENGTH}
|
||||
rows={2}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{showTextWarning && (
|
||||
@@ -877,15 +990,37 @@ export default function ChatInput({
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className={styles.contextPopoverContent}>
|
||||
<div className={styles.contextPopoverCategories}>
|
||||
<div
|
||||
className={styles.contextPopoverCategories}
|
||||
role="tablist"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Context categories"
|
||||
>
|
||||
{CONTEXT_CATEGORIES.map((category) => {
|
||||
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
|
||||
const isActive = activeContextCategory === category;
|
||||
return (
|
||||
<div
|
||||
<Button
|
||||
key={category}
|
||||
ref={(el): void => {
|
||||
categoryTabRefs.current.set(category, el);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
id={`ai-context-tab-${category}`}
|
||||
// Single stable panel id shared by every tab: only the
|
||||
// active category's tabpanel is rendered, so per-category
|
||||
// `aria-controls` ids would point at nonexistent nodes
|
||||
// for the two inactive tabs. APG allows a single
|
||||
// dynamic panel whose `aria-labelledby` swaps to the
|
||||
// active tab.
|
||||
aria-controls="ai-context-tabpanel"
|
||||
// Roving tabindex: only the active tab participates in
|
||||
// the Tab sequence; arrow keys move between tabs.
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
aria-selected={isActive}
|
||||
className={cx(styles.contextPopoverCategoryItem, {
|
||||
[styles.active]: isActive,
|
||||
@@ -894,22 +1029,21 @@ export default function ChatInput({
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e): void => handleCategoryKeyDown(e, category)}
|
||||
prefix={<CategoryIcon size={13} />}
|
||||
>
|
||||
<CategoryIcon size={13} />
|
||||
<span>{category}</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.contextPopoverRight}>
|
||||
<div
|
||||
className={styles.contextPopoverRight}
|
||||
role="tabpanel"
|
||||
id="ai-context-tabpanel"
|
||||
aria-labelledby={`ai-context-tab-${activeContextCategory}`}
|
||||
>
|
||||
<div className={styles.contextPopoverSearch}>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -939,7 +1073,7 @@ export default function ChatInput({
|
||||
No matching entities
|
||||
</div>
|
||||
) : (
|
||||
filteredContextOptions.map((option) => {
|
||||
filteredContextOptions.map((option, index) => {
|
||||
const isSelected = selectedContexts.some(
|
||||
(item) =>
|
||||
item.category === activeContextCategory &&
|
||||
@@ -947,8 +1081,16 @@ export default function ChatInput({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Button
|
||||
key={option.id}
|
||||
ref={(el): void => {
|
||||
entityRefs.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
aria-pressed={isSelected}
|
||||
className={cx(styles.contextPopoverEntityItem, {
|
||||
[styles.selected]: isSelected,
|
||||
})}
|
||||
@@ -959,11 +1101,12 @@ export default function ChatInput({
|
||||
option.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e): void => handleEntityKeyDown(e, index)}
|
||||
>
|
||||
<span className={styles.contextPopoverEntityItemText}>
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -977,14 +1120,24 @@ export default function ChatInput({
|
||||
<div className={styles.rightActions}>
|
||||
{showMic &&
|
||||
(isListening ? (
|
||||
<div className={styles.micRecording}>
|
||||
<div
|
||||
className={cx(styles.micDiscard, styles.secondary)}
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
>
|
||||
<X size={12} />
|
||||
</div>
|
||||
<div
|
||||
className={styles.micRecording}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Recording voice input"
|
||||
>
|
||||
<TooltipSimple title="Discard recording">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className={cx(styles.micDiscard, styles.secondary)}
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
prefix={<X size={12} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
<span className={styles.micWaves} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
@@ -995,26 +1148,30 @@ export default function ChatInput({
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<div
|
||||
className={cx(styles.micStop, styles.destructive)}
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
>
|
||||
<Square size={9} fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
<TooltipSimple title="Stop and send">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="destructive"
|
||||
className={cx(styles.micStop, styles.destructive)}
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
prefix={<Square size={9} fill="currentColor" strokeWidth={0} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title="Voice input">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(): void => startVoiceInput('button')}
|
||||
onClick={(): void => startVoiceInput(VoiceInputSource.Button)}
|
||||
disabled={disabled}
|
||||
aria-label="Start voice input"
|
||||
className={styles.micBtn}
|
||||
>
|
||||
<Mic size={14} />
|
||||
</Button>
|
||||
prefix={<Mic size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
|
||||
@@ -1026,21 +1183,21 @@ export default function ChatInput({
|
||||
color="destructive"
|
||||
onClick={onCancel}
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={10} fill="currentColor" strokeWidth={0} />
|
||||
</Button>
|
||||
prefix={<Square size={10} fill="currentColor" strokeWidth={0} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
color="primary"
|
||||
onClick={isListening ? handleStopAndSend : handleSend}
|
||||
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
<TooltipSimple title="Send message">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
color="primary"
|
||||
onClick={isListening ? handleStopAndSend : handleSend}
|
||||
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
|
||||
aria-label="Send message"
|
||||
prefix={<Send size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,19 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Mirrors `.field` for the multi_select group, but resets the browser's
|
||||
// default `<fieldset>` border/padding/margin so the visual matches the
|
||||
// `<div>`-based field rows.
|
||||
.multiSelectFieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -63,7 +63,14 @@ export default function ClarificationForm({
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const isFormValid = fields.every(
|
||||
(f) => !f.required || isFieldFilled(f, answers[f.id]),
|
||||
);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
// Approximate queryLength as the JSON encoding of the form answers — the
|
||||
// clarification API doesn't render a single user-visible string, but the
|
||||
@@ -136,7 +143,7 @@ export default function ClarificationForm({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isStreaming}
|
||||
disabled={isStreaming || !isFormValid}
|
||||
prefix={<Send />}
|
||||
>
|
||||
Submit
|
||||
@@ -162,8 +169,9 @@ export default function ClarificationForm({
|
||||
|
||||
/**
|
||||
* Per-type seed value. The DTO's `default` is `string | string[] | null`,
|
||||
* which doesn't fit boolean fields cleanly — we coerce 'true'/'false' strings
|
||||
* for them, fall back to `[]` for multi_select, and the raw string otherwise.
|
||||
* which doesn't fit boolean / number fields cleanly — we coerce 'true'/'false'
|
||||
* strings for booleans, parse number defaults out of the string form,
|
||||
* fall back to `[]` for multi_select, and the raw string otherwise.
|
||||
*/
|
||||
function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
|
||||
const raw = f.default;
|
||||
@@ -175,9 +183,41 @@ function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
|
||||
if (f.type === ClarificationFieldTypeDTO.multi_select) {
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
if (f.type === ClarificationFieldTypeDTO.number) {
|
||||
// Server sends number defaults as strings (e.g. `"5"`). Parse so the
|
||||
// stored value is a real `number` — otherwise `isFieldFilled` (which
|
||||
// requires `typeof === 'number'`) rejects a visibly-filled field and
|
||||
// Submit stays disabled.
|
||||
if (typeof raw !== 'string' || raw === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return raw ?? '';
|
||||
}
|
||||
|
||||
// Whether a required field has been answered. Booleans are always considered
|
||||
// filled (they're initialised to a concrete true/false). For other types we
|
||||
// reject empty strings, empty arrays, NaN numbers, and `null` (which the
|
||||
// number input emits when its raw value is `''` — `Number('')` would
|
||||
// otherwise silently coerce to `0` and read as a valid answer).
|
||||
function isFieldFilled(
|
||||
field: ClarificationFieldEventDTO,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
switch (field.type) {
|
||||
case ClarificationFieldTypeDTO.multi_select:
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
case ClarificationFieldTypeDTO.boolean:
|
||||
return true;
|
||||
case ClarificationFieldTypeDTO.number:
|
||||
return typeof value === 'number' && !Number.isNaN(value);
|
||||
default:
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
field: ClarificationFieldEventDTO;
|
||||
value: unknown;
|
||||
@@ -216,13 +256,21 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
{required && (
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
|
||||
onChange={handleSelectChange}
|
||||
>
|
||||
<SelectTrigger id={id} placeholder="Select…" />
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
placeholder="Select…"
|
||||
aria-required={required || undefined}
|
||||
/>
|
||||
{/* Pin the dropdown width to the trigger via Radix's
|
||||
`--radix-select-trigger-width`; otherwise the popover
|
||||
sizes to its widest item and looks misaligned. */}
|
||||
@@ -267,7 +315,11 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
onChange={(): void => onChange(!checked)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
{required && (
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
@@ -312,11 +364,21 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
// `fieldset` + `legend` is the WCAG-recommended grouping for
|
||||
// related checkboxes (1.3.1). SRs announce the legend before each
|
||||
// option, so users hear the group label as context.
|
||||
<fieldset
|
||||
className={styles.multiSelectFieldset}
|
||||
aria-required={required || undefined}
|
||||
>
|
||||
<legend className={styles.label}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
</span>
|
||||
{required && (
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</legend>
|
||||
<div className={styles.checkboxGroup}>
|
||||
{options?.map((opt) => (
|
||||
<Checkbox
|
||||
@@ -347,7 +409,7 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
onChange={(e): void => updateCustomValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,16 +418,29 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className={styles.required}>*</span>}
|
||||
{required && (
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
type={type === 'number' ? 'number' : 'text'}
|
||||
className={styles.input}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void =>
|
||||
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
aria-required={required || undefined}
|
||||
onChange={(e): void => {
|
||||
if (type === 'number') {
|
||||
const raw = e.target.value;
|
||||
// Map empty input to `null` instead of `Number('') === 0`
|
||||
// so a required numeric field cleared after typing doesn't
|
||||
// silently read as a valid `0` in `isFieldFilled`.
|
||||
onChange(raw === '' ? null : Number(raw));
|
||||
} else {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={label}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
{!isUser && !message.isRateLimitError && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { FeedbackRatingDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { AIAssistantEvents } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
@@ -17,6 +18,22 @@ import { FeedbackRating, Message } from '../../types';
|
||||
|
||||
import styles from './MessageFeedback.module.scss';
|
||||
|
||||
const FEEDBACK_ANALYTICS_RATING = {
|
||||
[FeedbackRatingDTO.positive]: 'up',
|
||||
[FeedbackRatingDTO.negative]: 'down',
|
||||
} as const;
|
||||
|
||||
const VOTE_LABEL = {
|
||||
[FeedbackRatingDTO.positive]: {
|
||||
tooltip: 'Good response',
|
||||
ariaLabel: 'Good response',
|
||||
},
|
||||
[FeedbackRatingDTO.negative]: {
|
||||
tooltip: 'Bad response',
|
||||
ariaLabel: 'Bad response',
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface MessageFeedbackProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
@@ -117,7 +134,7 @@ export default function MessageFeedback({
|
||||
if (vote === rating) {
|
||||
return;
|
||||
}
|
||||
if (rating === 'negative') {
|
||||
if (rating === FeedbackRatingDTO.negative) {
|
||||
setNegativeComment('');
|
||||
setIsNegativeDialogOpen(true);
|
||||
return;
|
||||
@@ -126,7 +143,7 @@ export default function MessageFeedback({
|
||||
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
|
||||
messageId: message.id,
|
||||
threadId,
|
||||
rating: 'up',
|
||||
rating: FEEDBACK_ANALYTICS_RATING[rating],
|
||||
hasComment: false,
|
||||
commentLength: 0,
|
||||
});
|
||||
@@ -136,17 +153,21 @@ export default function MessageFeedback({
|
||||
);
|
||||
|
||||
const handleSubmitNegative = useCallback((): void => {
|
||||
setVote('negative');
|
||||
setVote(FeedbackRatingDTO.negative);
|
||||
setIsNegativeDialogOpen(false);
|
||||
const trimmed = negativeComment.trim();
|
||||
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
|
||||
messageId: message.id,
|
||||
threadId,
|
||||
rating: 'down',
|
||||
rating: FEEDBACK_ANALYTICS_RATING[FeedbackRatingDTO.negative],
|
||||
hasComment: trimmed.length > 0,
|
||||
commentLength: trimmed.length,
|
||||
});
|
||||
submitMessageFeedback(message.id, 'negative', trimmed || undefined);
|
||||
submitMessageFeedback(
|
||||
message.id,
|
||||
FeedbackRatingDTO.negative,
|
||||
trimmed || undefined,
|
||||
);
|
||||
}, [message.id, negativeComment, submitMessageFeedback, threadId]);
|
||||
|
||||
return (
|
||||
@@ -160,32 +181,39 @@ export default function MessageFeedback({
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
color="secondary"
|
||||
aria-label={copied ? 'Copied' : 'Copy message'}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Good response">
|
||||
<TooltipSimple title={VOTE_LABEL[FeedbackRatingDTO.positive].tooltip}>
|
||||
<Button
|
||||
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
|
||||
className={cx(styles.btn, {
|
||||
[styles.votedUp]: vote === FeedbackRatingDTO.positive,
|
||||
})}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => handleVote('positive')}
|
||||
onClick={(): void => handleVote(FeedbackRatingDTO.positive)}
|
||||
aria-label={VOTE_LABEL[FeedbackRatingDTO.positive].ariaLabel}
|
||||
aria-pressed={vote === FeedbackRatingDTO.positive}
|
||||
>
|
||||
<ThumbsUp size={12} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
|
||||
<TooltipSimple title="Bad response">
|
||||
<TooltipSimple title={VOTE_LABEL[FeedbackRatingDTO.negative].tooltip}>
|
||||
<Button
|
||||
className={cx(styles.btn, {
|
||||
[styles.votedDown]: vote === 'negative',
|
||||
[styles.votedDown]: vote === FeedbackRatingDTO.negative,
|
||||
})}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => handleVote('negative')}
|
||||
onClick={(): void => handleVote(FeedbackRatingDTO.negative)}
|
||||
aria-label={VOTE_LABEL[FeedbackRatingDTO.negative].ariaLabel}
|
||||
aria-pressed={vote === FeedbackRatingDTO.negative}
|
||||
>
|
||||
<ThumbsDown size={12} />
|
||||
</Button>
|
||||
@@ -199,6 +227,7 @@ export default function MessageFeedback({
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onRegenerate}
|
||||
aria-label="Regenerate response"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function UserMessageActions({
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? 'Copied' : 'Copy message'}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
|
||||
@@ -90,6 +90,16 @@ export default function VirtualizedMessages({
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const scrollerRef = useRef<HTMLElement | Window | null>(null);
|
||||
// Tracks whether the scroller is pinned to (or near) the bottom. Updated
|
||||
// via Virtuoso's `atBottomStateChange` so we can stop force-scrolling the
|
||||
// user back down when they've intentionally scrolled up to read earlier
|
||||
// content.
|
||||
const atBottomRef = useRef(true);
|
||||
// Id of the latest user message we've already anchored to. Used to detect
|
||||
// a fresh user send so we can re-anchor to the bottom regardless of where
|
||||
// the user was scrolled — sending a message and not seeing it is worse
|
||||
// than the anti-yank guarantee.
|
||||
const lastSeenUserMessageIdRef = useRef<string | null>(null);
|
||||
|
||||
const handleRegenerate = useCallback(
|
||||
(messageId: string): void => {
|
||||
@@ -111,8 +121,25 @@ export default function VirtualizedMessages({
|
||||
// align: 'end')` would only reach the last item's bottom and leave the
|
||||
// padding hidden below the fold. Use `auto` while streaming so the bottom
|
||||
// stays glued as text deltas arrive; `smooth` lags when triggered every
|
||||
// few ms.
|
||||
// few ms. Bail out if the user has scrolled away from the bottom — that's
|
||||
// an explicit signal they want to read earlier content without being
|
||||
// yanked back.
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const isFreshUserSend =
|
||||
lastMessage?.role === 'user' &&
|
||||
lastMessage.id !== lastSeenUserMessageIdRef.current;
|
||||
if (isFreshUserSend) {
|
||||
lastSeenUserMessageIdRef.current = lastMessage.id;
|
||||
// Re-anchor so the user sees their own send (and the assistant's
|
||||
// follow-up streaming) even if they were reading history when they
|
||||
// hit Enter.
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
|
||||
if (!atBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
const scroller = scrollerRef.current;
|
||||
if (!(scroller instanceof HTMLElement)) {
|
||||
return;
|
||||
@@ -122,7 +149,7 @@ export default function VirtualizedMessages({
|
||||
behavior: isStreaming ? 'auto' : 'smooth',
|
||||
});
|
||||
}, [
|
||||
messages.length,
|
||||
messages,
|
||||
streamingEvents.length,
|
||||
streamingContentLength,
|
||||
isStreaming,
|
||||
@@ -132,14 +159,18 @@ export default function VirtualizedMessages({
|
||||
|
||||
const followOutput = useCallback(
|
||||
(atBottom: boolean): false | 'auto' | 'smooth' => {
|
||||
if (isStreaming) {
|
||||
return 'auto';
|
||||
if (!atBottom) {
|
||||
return false;
|
||||
}
|
||||
return atBottom ? 'smooth' : false;
|
||||
return isStreaming ? 'auto' : 'smooth';
|
||||
},
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
const handleAtBottomStateChange = useCallback((atBottom: boolean): void => {
|
||||
atBottomRef.current = atBottom;
|
||||
}, []);
|
||||
|
||||
const showStreamingSlot =
|
||||
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
|
||||
|
||||
@@ -188,6 +219,8 @@ export default function VirtualizedMessages({
|
||||
className={styles.messages}
|
||||
totalCount={totalCount}
|
||||
followOutput={followOutput}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
atBottomThreshold={64}
|
||||
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
|
||||
itemContent={(index): JSX.Element => {
|
||||
if (index < messages.length) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Checkbox, Radio } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
|
||||
import { AIAssistantEvents } from '../../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -81,31 +82,43 @@ export default function InteractiveQuestion({
|
||||
{question && <p className={blockStyles.title}>{question}</p>}
|
||||
|
||||
{type === 'radio' ? (
|
||||
<Radio.Group
|
||||
<RadioGroup
|
||||
className={styles.options}
|
||||
onChange={(e): void => {
|
||||
setSelected([e.target.value]);
|
||||
handleSubmit([e.target.value]);
|
||||
onChange={(value): void => {
|
||||
setSelected([value]);
|
||||
handleSubmit([value]);
|
||||
}}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Radio key={opt.value} value={opt.value} className={styles.option}>
|
||||
<RadioGroupItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className={styles.option}
|
||||
>
|
||||
{opt.label}
|
||||
</Radio>
|
||||
</RadioGroupItem>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox.Group
|
||||
className={cx(styles.options, styles.checkbox)}
|
||||
onChange={(vals): void => setSelected(vals as string[])}
|
||||
>
|
||||
<div className={cx(styles.options, styles.checkbox)}>
|
||||
{normalized.map((opt) => (
|
||||
<Checkbox key={opt.value} value={opt.value} className={styles.option}>
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
value={selected.includes(opt.value)}
|
||||
onChange={(checked): void => {
|
||||
setSelected((prev) =>
|
||||
checked === true
|
||||
? [...prev, opt.value]
|
||||
: prev.filter((v) => v !== opt.value),
|
||||
);
|
||||
}}
|
||||
className={styles.option}
|
||||
>
|
||||
{opt.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user