mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-26 20:00:33 +01:00
Compare commits
15 Commits
promooted-
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e63e612c8 | ||
|
|
8fadd9fb08 | ||
|
|
0748565583 | ||
|
|
dd629a0e97 | ||
|
|
c76f796e39 | ||
|
|
73f2a785d3 | ||
|
|
4c473d3ce2 | ||
|
|
54705e73f2 | ||
|
|
f1d7f727fe | ||
|
|
f963a98953 | ||
|
|
029f7196b2 | ||
|
|
9976f7c95f | ||
|
|
37133429a2 | ||
|
|
f8479e33ba | ||
|
|
bb06867cd7 |
17
.github/workflows/goci.yaml
vendored
17
.github/workflows/goci.yaml
vendored
@@ -123,20 +123,3 @@ jobs:
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in authz permissions. Run go run cmd/enterprise/*.go generate authz locally and commit."; exit 1)
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
|
||||
|
||||
23
.github/workflows/jsci.yaml
vendored
23
.github/workflows/jsci.yaml
vendored
@@ -90,26 +90,3 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
cd frontend && pnpm generate:config:web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated web settings types. Run pnpm generate:config:web-settings in frontend/ locally and commit."; exit 1)
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
|
||||
|
||||
COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN apk update && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz-community
|
||||
|
||||
@@ -115,7 +115,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
func(_ sqlstore.SQLStore, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN apk update && \
|
||||
|
||||
|
||||
COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN go mod download
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN apk update && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
COPY ./target/${OS}-${ARCH}/signoz /root/signoz
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN go mod download
|
||||
COPY ./cmd/ ./cmd/
|
||||
COPY ./ee/ ./ee/
|
||||
COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
COPY ./templates/email /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
|
||||
@@ -167,7 +167,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, dashboardModule dashboard.Module, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
func(sqlStore sqlstore.SQLStore, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
defStore := pkgcloudintegration.NewServiceDefinitionStore()
|
||||
awsCloudProviderModule, err := implcloudprovider.NewAWSCloudProvider(defStore)
|
||||
if err != nil {
|
||||
@@ -179,7 +179,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
|
||||
}
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
const webSettingsSchemaPath = "docs/config/web-settings.json"
|
||||
|
||||
func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
configCmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Generate JSON Schema for config",
|
||||
}
|
||||
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "web-settings",
|
||||
Short: "Generate JSON Schema for web settings",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return generateWebSettings()
|
||||
},
|
||||
})
|
||||
|
||||
parentCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
func generateWebSettings() error {
|
||||
falseVal := false
|
||||
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
|
||||
|
||||
reflector := jsonschema.Reflector{}
|
||||
reflector.DefaultOptions = append(reflector.DefaultOptions,
|
||||
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
|
||||
if params.Value.Kind() == reflect.Struct {
|
||||
params.Schema.AdditionalProperties = &noAdditional
|
||||
}
|
||||
return false, nil
|
||||
}),
|
||||
jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
|
||||
return strings.TrimPrefix(defaultDefName, "Web")
|
||||
}),
|
||||
)
|
||||
|
||||
schema, err := reflector.Reflect(web.Settings{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
@@ -17,7 +17,6 @@ func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
|
||||
|
||||
registerGenerateOpenAPI(generateCmd)
|
||||
registerGenerateAuthz(generateCmd)
|
||||
registerGenerateConfig(generateCmd)
|
||||
|
||||
parentCmd.AddCommand(generateCmd)
|
||||
}
|
||||
|
||||
@@ -60,14 +60,6 @@ web:
|
||||
index: index.html
|
||||
# The directory containing the static build files.
|
||||
directory: /etc/signoz/web
|
||||
# Settings exposed to the web.
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: true
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: true
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
@@ -182,11 +174,6 @@ alertmanager:
|
||||
poll_interval: 1m
|
||||
# The URL under which Alertmanager is externally reachable (for example, if Alertmanager is served via a reverse proxy). Used for generating relative and absolute links back to Alertmanager itself.
|
||||
external_url: http://localhost:8080
|
||||
# The list of globs from which SigNoz's alertmanager notification templates are loaded (e.g. the email.signoz.html layout).
|
||||
# This mirrors the upstream alertmanager `templates` config option. The upstream default templates (default.tmpl, email.tmpl)
|
||||
# are always loaded from the embedded alertmanager assets, so only SigNoz's own templates need to be listed here.
|
||||
templates:
|
||||
- /opt/signoz/conf/templates/alertmanager/*.gotmpl
|
||||
# The global configuration for the alertmanager. All the exahustive fields can be found in the upstream: https://github.com/prometheus/alertmanager/blob/efa05feffd644ba4accb526e98a8c6545d26a783/config/config.go#L833
|
||||
global:
|
||||
# ResolveTimeout is the time after which an alert is declared resolved if it has not been updated.
|
||||
|
||||
1777
docs/api/openapi.yml
1777
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"required": [
|
||||
"posthog",
|
||||
"appcues"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"Appcues": {
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Posthog": {
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"appcues": {
|
||||
"$ref": "#/definitions/Appcues"
|
||||
},
|
||||
"posthog": {
|
||||
"$ref": "#/definitions/Posthog"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
@@ -54,6 +54,11 @@ func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// override cloud integration dashboard id
|
||||
for index, dashboard := range serviceDef.Assets.Dashboards {
|
||||
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cloudintegrationtypes.CloudProviderTypeAWS, serviceID.StringValue(), dashboard.ID)
|
||||
}
|
||||
|
||||
return serviceDef, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, se
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// override cloud integration dashboard id.
|
||||
for index, dashboard := range serviceDef.Assets.Dashboards {
|
||||
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cloudintegrationtypes.CloudProviderTypeAzure, serviceID.StringValue(), dashboard.ID)
|
||||
}
|
||||
|
||||
return serviceDef, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package implcloudintegration
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
|
||||
type module struct {
|
||||
store cloudintegrationtypes.Store
|
||||
dashboardModule dashboard.Module
|
||||
gateway gateway.Gateway
|
||||
zeus zeus.Zeus
|
||||
licensing licensing.Licensing
|
||||
@@ -35,7 +34,6 @@ type module struct {
|
||||
|
||||
func NewModule(
|
||||
store cloudintegrationtypes.Store,
|
||||
dashboardModule dashboard.Module,
|
||||
global global.Global,
|
||||
zeus zeus.Zeus,
|
||||
gateway gateway.Gateway,
|
||||
@@ -46,7 +44,6 @@ func NewModule(
|
||||
) (cloudintegration.Module, error) {
|
||||
return &module{
|
||||
store: store,
|
||||
dashboardModule: dashboardModule,
|
||||
global: global,
|
||||
zeus: zeus,
|
||||
gateway: gateway,
|
||||
@@ -257,41 +254,7 @@ func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID,
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
services, err := module.store.ListServices(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sharedServices, err := module.store.ListSharedServices(ctx, orgID, provider, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, svc.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !svcCfg.IsMetricsEnabled(provider) {
|
||||
continue
|
||||
}
|
||||
|
||||
if cloudintegrationtypes.IsServiceSharedWithMetricsEnabled(provider, sharedServices[svc.Type]) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := module.deprovisionDashboards(ctx, orgID, provider, svc.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := module.store.DeleteServicesByCloudIntegrationID(ctx, orgID, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.RemoveAccount(ctx, orgID, accountID, provider)
|
||||
})
|
||||
return module.store.RemoveAccount(ctx, orgID, accountID, provider)
|
||||
}
|
||||
|
||||
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
|
||||
@@ -368,16 +331,12 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
}
|
||||
|
||||
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -398,21 +357,10 @@ func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, crea
|
||||
return err
|
||||
}
|
||||
|
||||
metricsEnabled := service.Config.IsMetricsEnabled(provider)
|
||||
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(service, string(configJSON))
|
||||
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.CreateService(ctx, storableService); err != nil {
|
||||
return err
|
||||
}
|
||||
if metricsEnabled {
|
||||
return module.provisionDashboards(ctx, orgID, createdBy, creator, provider, service, serviceDefinition)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return module.store.CreateService(ctx, cloudintegrationtypes.NewStorableCloudIntegrationService(service, string(configJSON)))
|
||||
}
|
||||
|
||||
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
@@ -433,28 +381,43 @@ func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, crea
|
||||
return err
|
||||
}
|
||||
|
||||
metricsEnabled := integrationService.Config.IsMetricsEnabled(provider)
|
||||
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(integrationService, string(configJSON))
|
||||
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.UpdateService(ctx, storableService); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.UpdateService(ctx, storableService)
|
||||
}
|
||||
|
||||
if metricsEnabled {
|
||||
return module.provisionDashboards(ctx, orgID, createdBy, creator, provider, integrationService, serviceDefinition)
|
||||
}
|
||||
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
sharedServices, err := module.store.ListSharedServices(ctx, orgID, provider, integrationService.CloudIntegrationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cloudintegrationtypes.IsServiceSharedWithMetricsEnabled(provider, sharedServices[integrationService.Type]) {
|
||||
return nil
|
||||
}
|
||||
_, _, _, err = cloudintegrationtypes.ParseCloudIntegrationDashboardID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.deprovisionDashboards(ctx, orgID, provider, integrationService.Type)
|
||||
})
|
||||
allDashboards, err := module.listDashboards(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range allDashboards {
|
||||
if d.ID == id {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration dashboard not found")
|
||||
}
|
||||
|
||||
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return module.listDashboards(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
@@ -530,73 +493,52 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
|
||||
return factorAPIKey.Key, nil
|
||||
}
|
||||
|
||||
// provisionDashboards creates dashboard and integration_dashboard rows for each dashboard in the service definition.
|
||||
// Must be called within a transaction (ctx carries the tx).
|
||||
func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, provider cloudintegrationtypes.CloudProviderType, service *cloudintegrationtypes.CloudIntegrationService, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
// TODO: DB calls are in for loop, can be optimized later.
|
||||
for _, dashboard := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, service.Type, dashboard.ID)
|
||||
func (module *module) listDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
var allDashboards []*dashboardtypes.Dashboard
|
||||
|
||||
existing, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
createdDashboard, err := module.dashboardModule.Create(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboardtypes.PostableDashboard(dashboard.Definition))
|
||||
for provider := range module.cloudProvidersMap {
|
||||
cloudProvider, err := module.getCloudProvider(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err := module.store.CreateIntegrationDashboard(ctx, integrationDashboard); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deprovisionDashboards deletes all dashboard and integration_dashboard rows for the given service.
|
||||
// make sure to call this within a transaction.
|
||||
func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID) error {
|
||||
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
rows, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
dashID, err := valuer.NewUUID(row.DashboardID)
|
||||
connectedAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.DeleteIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, row.Slug); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.dashboardModule.DeleteUnsafe(ctx, orgID, dashID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
|
||||
// TODO: remove this hack and send idiomatic response to client.
|
||||
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
for i, d := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
continue
|
||||
for _, storableAccount := range connectedAccounts {
|
||||
storedServices, err := module.store.ListServices(ctx, storableAccount.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, storedSvc := range storedServices {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedSvc.Config)
|
||||
if err != nil || !serviceConfig.IsMetricsEnabled(provider) {
|
||||
continue
|
||||
}
|
||||
|
||||
svcDef, err := cloudProvider.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil || svcDef == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dashboards := cloudintegrationtypes.GetDashboardsFromAssets(
|
||||
storedSvc.Type.StringValue(),
|
||||
orgID,
|
||||
provider,
|
||||
storableAccount.CreatedAt,
|
||||
svcDef.Assets,
|
||||
)
|
||||
allDashboards = append(allDashboards, dashboards...)
|
||||
}
|
||||
return err
|
||||
}
|
||||
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
|
||||
}
|
||||
return nil
|
||||
|
||||
sort.Slice(allDashboards, func(i, j int) bool {
|
||||
return allDashboards[i].ID < allDashboards[j].ID
|
||||
})
|
||||
|
||||
return allDashboards, nil
|
||||
}
|
||||
|
||||
@@ -162,11 +162,24 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "dashboard is locked, please unlock the dashboard to be delete it")
|
||||
}
|
||||
|
||||
return module.delete(ctx, orgID, id)
|
||||
}
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err := module.store.DeletePublic(ctx, id.String())
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
func (module *module) DeleteUnsafe(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.delete(ctx, orgID, id)
|
||||
err = module.store.Delete(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
|
||||
@@ -208,8 +221,8 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, source, data)
|
||||
func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, data dashboardtypes.PostableDashboard) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
@@ -231,12 +244,3 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
|
||||
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return module.store.Delete(ctx, orgID, id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
@@ -23,6 +24,7 @@ type APIHandlerOptions struct {
|
||||
DataConnector interfaces.Reader
|
||||
UsageManager *usage.Manager
|
||||
IntegrationsController *integrations.Controller
|
||||
CloudIntegrationsController *cloudintegrations.Controller
|
||||
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
|
||||
GatewayUrl string
|
||||
// Querier Influx Interval
|
||||
@@ -40,6 +42,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
@@ -88,6 +91,17 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
|
||||
|
||||
router.HandleFunc(
|
||||
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
|
||||
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
versionResponse := basemodel.GetVersionResponse{
|
||||
Version: version.Info.Version(),
|
||||
|
||||
@@ -89,15 +89,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
|
||||
Active: useDashboardV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
@@ -85,13 +86,20 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
// initiate opamp
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
integrationsController, err := integrations.NewController(signoz.SQLStore, signoz.Modules.Dashboard)
|
||||
integrationsController, err := integrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create cloud provider integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// ingestion pipelines manager
|
||||
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
|
||||
signoz.SQLStore,
|
||||
@@ -126,6 +134,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
DataConnector: reader,
|
||||
UsageManager: usageManager,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
LogsParsingPipelineController: logParsingPipelineController,
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
GatewayUrl: config.Gateway.URL.String(),
|
||||
@@ -191,6 +200,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterInfraMetricsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
|
||||
@@ -94,19 +94,6 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="signoz-boot-settings">
|
||||
[[.Settings]]
|
||||
</script>
|
||||
<script>
|
||||
try {
|
||||
var _el = document.getElementById('signoz-boot-settings');
|
||||
window.signozBootData = {
|
||||
settings: _el ? JSON.parse(_el.textContent) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
window.signozBootData = { settings: null };
|
||||
}
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -148,10 +135,7 @@
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
if (APPCUES_APP_ID) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
|
||||
@@ -47,10 +47,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
|
||||
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
"commitlint": "commitlint --edit $1",
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
@@ -50,7 +49,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.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -161,8 +160,8 @@
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
@@ -188,7 +187,6 @@
|
||||
"is-ci": "^3.0.1",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"lint-staged": "^17.0.4",
|
||||
"msw": "1.3.2",
|
||||
"orval": "8.9.1",
|
||||
@@ -243,4 +241,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
frontend/pnpm-lock.yaml
generated
58
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.21
|
||||
version: 0.0.21(@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)
|
||||
@@ -449,9 +449,6 @@ importers:
|
||||
jest-styled-components:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0(styled-components@5.3.11(react-dom@18.2.0(react@18.2.0))(react-is@19.2.6)(react@18.2.0))
|
||||
json-schema-to-typescript:
|
||||
specifier: ^15.0.4
|
||||
version: 15.0.4
|
||||
lint-staged:
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4
|
||||
@@ -460,7 +457,7 @@ importers:
|
||||
version: 1.3.2(typescript@5.9.3)
|
||||
orval:
|
||||
specifier: 8.9.1
|
||||
version: 8.9.1(prettier@3.8.3)(typescript@5.9.3)
|
||||
version: 8.9.1(typescript@5.9.3)
|
||||
oxfmt:
|
||||
specifier: 0.47.0
|
||||
version: 0.47.0
|
||||
@@ -548,10 +545,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.9.0'
|
||||
|
||||
'@apidevtools/json-schema-ref-parser@11.9.3':
|
||||
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1998,9 +1991,6 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@jsdevtools/ono@7.1.3':
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
|
||||
'@keyv/bigmap@1.3.1':
|
||||
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -3279,8 +3269,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.21':
|
||||
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
|
||||
peerDependencies:
|
||||
'@signozhq/icons': 0.3.0
|
||||
react: ^18.2.0
|
||||
@@ -6076,11 +6066,6 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-to-typescript@15.0.4:
|
||||
resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -7119,11 +7104,6 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.8.3:
|
||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-format@27.5.1:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
@@ -9064,12 +9044,6 @@ snapshots:
|
||||
resize-observer-polyfill: 1.5.1
|
||||
throttle-debounce: 5.0.0
|
||||
|
||||
'@apidevtools/json-schema-ref-parser@11.9.3':
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.1.1
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -10824,8 +10798,6 @@ snapshots:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
optional: true
|
||||
|
||||
'@jsdevtools/ono@7.1.3': {}
|
||||
|
||||
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
|
||||
dependencies:
|
||||
hashery: 1.5.1
|
||||
@@ -12041,7 +12013,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.21(@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)
|
||||
@@ -15402,18 +15374,6 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-to-typescript@15.0.4:
|
||||
dependencies:
|
||||
'@apidevtools/json-schema-ref-parser': 11.9.3
|
||||
'@types/json-schema': 7.0.15
|
||||
'@types/lodash': 4.17.24
|
||||
is-glob: 4.0.3
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.18.1
|
||||
minimist: 1.2.8
|
||||
prettier: 3.8.3
|
||||
tinyglobby: 0.2.15
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -16330,7 +16290,7 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wcwidth: 1.0.1
|
||||
|
||||
orval@8.9.1(prettier@3.8.3)(typescript@5.9.3):
|
||||
orval@8.9.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@commander-js/extra-typings': 14.0.0(commander@14.0.2)
|
||||
'@orval/angular': 8.9.1(typescript@5.9.3)
|
||||
@@ -16361,8 +16321,6 @@ snapshots:
|
||||
typedoc: 0.28.19(typescript@5.9.3)
|
||||
typedoc-plugin-coverage: 4.0.2(typedoc@0.28.19(typescript@5.9.3))
|
||||
typedoc-plugin-markdown: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
|
||||
optionalDependencies:
|
||||
prettier: 3.8.3
|
||||
transitivePeerDependencies:
|
||||
- '@faker-js/faker'
|
||||
- supports-color
|
||||
@@ -16623,8 +16581,6 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.3: {}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
import { bootSettings } from 'utils/bootData';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
@@ -333,7 +332,7 @@ function App(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
|
||||
if (process.env.POSTHOG_KEY) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
|
||||
@@ -18,18 +18,33 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesJSONPatchDocumentDTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
GetDashboardV2PathParameters,
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
ListDashboardsV2200,
|
||||
ListDashboardsV2Params,
|
||||
LockDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
PinDashboardV2PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UnlockDashboardV2PathParameters,
|
||||
UnpinDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -628,3 +643,799 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
|
||||
* @summary List dashboards (v2)
|
||||
*/
|
||||
export const listDashboardsV2 = (
|
||||
params?: ListDashboardsV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListDashboardsV2200>({
|
||||
url: `/api/v2/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListDashboardsV2QueryKey = (
|
||||
params?: ListDashboardsV2Params,
|
||||
) => {
|
||||
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListDashboardsV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
|
||||
signal,
|
||||
}) => listDashboardsV2(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListDashboardsV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>
|
||||
>;
|
||||
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List dashboards (v2)
|
||||
*/
|
||||
|
||||
export function useListDashboardsV2<
|
||||
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListDashboardsV2QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List dashboards (v2)
|
||||
*/
|
||||
export const invalidateListDashboardsV2 = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListDashboardsV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListDashboardsV2QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
|
||||
* @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 with its tags and public sharing config (if any).
|
||||
* @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;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const patchDashboardV2 = (
|
||||
{ id }: PatchDashboardV2PathParameters,
|
||||
dashboardtypesJSONPatchDocumentDTONull?: BodyType<DashboardtypesJSONPatchDocumentDTO | null> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesJSONPatchDocumentDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchDashboardV2'];
|
||||
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 patchDashboardV2>>,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>
|
||||
>;
|
||||
export type PatchDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesJSONPatchDocumentDTO | null>
|
||||
| undefined;
|
||||
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const usePatchDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const updateDashboardV2 = (
|
||||
{ id }: UpdateDashboardV2PathParameters,
|
||||
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPostableDashboardV2DTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardV2'];
|
||||
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 updateDashboardV2>>,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>
|
||||
>;
|
||||
export type UpdateDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesPostableDashboardV2DTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const useUpdateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnlockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unlockDashboardV2'];
|
||||
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 unlockDashboardV2>>,
|
||||
{ pathParams: UnlockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unlockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const useUnlockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnlockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['lockDashboardV2'];
|
||||
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 lockDashboardV2>>,
|
||||
{ pathParams: LockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return lockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type LockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const useLockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
|
||||
* @summary Unpin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const unpinDashboardV2 = (
|
||||
{ id }: UnpinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/pins/me`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnpinDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unpinDashboardV2'];
|
||||
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 unpinDashboardV2>>,
|
||||
{ pathParams: UnpinDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unpinDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnpinDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unpin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const useUnpinDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unpinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnpinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnpinDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
|
||||
* @summary Pin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const pinDashboardV2 = (
|
||||
{ id }: PinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/pins/me`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPinDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['pinDashboardV2'];
|
||||
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 pinDashboardV2>>,
|
||||
{ pathParams: PinDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return pinDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PinDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>
|
||||
>;
|
||||
|
||||
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Pin a dashboard for the current user (v2)
|
||||
*/
|
||||
export const usePinDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof pinDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: PinDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPinDashboardV2MutationOptions(options));
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
TracedetailtypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -27,14 +27,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
*/
|
||||
export const getWaterfall = (
|
||||
{ traceID }: GetWaterfallPathParameters,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
tracedetailtypesPostableWaterfallDTO?: BodyType<TracedetailtypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfall200>({
|
||||
url: `/api/v3/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
data: tracedetailtypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -48,7 +48,7 @@ export const getGetWaterfallMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -57,7 +57,7 @@ export const getGetWaterfallMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -74,7 +74,7 @@ export const getGetWaterfallMutationOptions = <
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -89,7 +89,7 @@ export type GetWaterfallMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfall>>
|
||||
>;
|
||||
export type GetWaterfallMutationBody =
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
| BodyType<TracedetailtypesPostableWaterfallDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
@@ -105,7 +105,7 @@ export const useGetWaterfall = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -114,7 +114,7 @@ export const useGetWaterfall = <
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
data?: BodyType<TracedetailtypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
@@ -5,10 +5,7 @@ import cx from 'classnames';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import {
|
||||
getBodyDisplayString,
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -199,7 +196,7 @@ function ListLogView({
|
||||
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||
<LogGeneralField
|
||||
fieldKey="Log"
|
||||
fieldValue={getBodyDisplayString(logData.body)}
|
||||
fieldValue={flattenLogData.body}
|
||||
linesPerRow={linesPerRow}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
|
||||
@@ -51,10 +51,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.duration-input-slider {
|
||||
padding: 12px 0px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
||||
@@ -10,6 +10,9 @@ export const DEFAULT_AUTH0_APP_REDIRECTION_PATH = ROUTES.APPLICATION;
|
||||
|
||||
export const INVITE_MEMBERS_HASH = '#invite-team-members';
|
||||
|
||||
export const SIGNOZ_UPGRADE_PLAN_URL =
|
||||
'https://upgrade.signoz.io/upgrade-from-app';
|
||||
|
||||
export const DASHBOARD_TIME_IN_DURATION = 'refreshInterval';
|
||||
|
||||
export const DEFAULT_ENTITY_VERSION = 'v3';
|
||||
|
||||
@@ -11,5 +11,5 @@ export enum FeatureKeys {
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
DASHBOARD_V2 = 'dashboard_v2',
|
||||
}
|
||||
|
||||
@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// Collapsed activity summary — one row that hides the underlying
|
||||
// thinking + tool-call steps. Reuses the same quiet treatment as
|
||||
// ThinkingStep / ToolCallStep so it sits flush in the assistant bubble.
|
||||
.activityGroup {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.activityHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--l3-foreground);
|
||||
user-select: none;
|
||||
transition: color 0.12s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.sparkleIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-primary);
|
||||
|
||||
&.iconPulsing {
|
||||
animation: activityGroupPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes activityGroupPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.activitySummary {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toggleChevron {
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.activityBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { ChevronDown, ChevronRight, Sparkles } from '@signozhq/icons';
|
||||
|
||||
import { formatTime } from 'utils/timeUtils';
|
||||
|
||||
import { StreamingToolCall } from '../../types';
|
||||
import ThinkingStep, { ThinkingContent, thinkingLabel } from '../ThinkingStep';
|
||||
import ToolCallStep, {
|
||||
getToolDisplayLabel,
|
||||
ToolCallContent,
|
||||
} from '../ToolCallStep';
|
||||
|
||||
import styles from './ActivityGroup.module.scss';
|
||||
|
||||
export type ActivityItem =
|
||||
| { id: string; kind: 'thinking'; content: string }
|
||||
| { id: string; kind: 'tool'; toolCall: StreamingToolCall };
|
||||
|
||||
interface ActivityGroupProps {
|
||||
items: ActivityItem[];
|
||||
/**
|
||||
* True only for the trailing activity group of an active stream — drives
|
||||
* the live "Working…" label and the elapsed-time tick (which re-stamps on
|
||||
* approval/clarification resume so wait time isn't counted).
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-item groups get a step-specific summary so the user doesn't see a
|
||||
* pointless "Worked through 1 step". Multi-item groups roll up into the
|
||||
* generic "Working… / Worked through N steps" treatment.
|
||||
*/
|
||||
function buildSummary(
|
||||
items: ActivityItem[],
|
||||
isLive: boolean,
|
||||
elapsed: number,
|
||||
): string {
|
||||
if (items.length === 1) {
|
||||
const [only] = items;
|
||||
if (only.kind === 'thinking') {
|
||||
return thinkingLabel(isLive);
|
||||
}
|
||||
return getToolDisplayLabel(only.toolCall);
|
||||
}
|
||||
const stepLabel = `${items.length} steps`;
|
||||
if (!isLive) {
|
||||
return `Worked through ${stepLabel}`;
|
||||
}
|
||||
// Suppress the elapsed token until ≥ 1s — the first tick fires after
|
||||
// 1s anyway, and showing "0s" or "<1s" briefly adds noise.
|
||||
return elapsed >= 1000
|
||||
? `Working… · ${formatTime(elapsed / 1000)} · ${stepLabel}`
|
||||
: `Working… · ${stepLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single collapsed summary row that hides a run of thinking + tool-call steps.
|
||||
* Expands to show each underlying step inline. Used for every activity row
|
||||
* (including single-item ones) so all "what the agent did" rows share a
|
||||
* consistent ✨-led visual contract.
|
||||
*/
|
||||
export default function ActivityGroup({
|
||||
items,
|
||||
isLive = false,
|
||||
}: ActivityGroupProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Captures the moment this live phase started. Re-stamped on every
|
||||
// false→true transition so a stream that pauses on
|
||||
// approval/clarification and resumes doesn't roll the user's wait time
|
||||
// into the elapsed counter.
|
||||
const startedAtRef = useRef<number>(Date.now());
|
||||
const wasLiveRef = useRef<boolean>(isLive);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLive && !wasLiveRef.current) {
|
||||
startedAtRef.current = Date.now();
|
||||
setElapsed(0);
|
||||
}
|
||||
wasLiveRef.current = isLive;
|
||||
|
||||
if (!isLive) {
|
||||
return undefined;
|
||||
}
|
||||
// Tick once per second — the display is integer-second precision, so
|
||||
// faster ticks would just re-render the bubble for no visible change.
|
||||
const id = window.setInterval(() => {
|
||||
setElapsed(Date.now() - startedAtRef.current);
|
||||
}, 1000);
|
||||
return (): void => window.clearInterval(id);
|
||||
}, [isLive]);
|
||||
|
||||
const summary = buildSummary(items, isLive, elapsed);
|
||||
const isSingle = items.length === 1;
|
||||
|
||||
const toggle = (): void => setExpanded((v) => !v);
|
||||
|
||||
return (
|
||||
<div className={styles.activityGroup}>
|
||||
<div className={styles.activityHeader} onClick={toggle}>
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={cx(styles.sparkleIcon, { [styles.iconPulsing]: isLive })}
|
||||
/>
|
||||
<span className={styles.activitySummary}>{summary}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={12} className={styles.toggleChevron} />
|
||||
) : (
|
||||
<ChevronRight size={12} className={styles.toggleChevron} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.activityBody}>
|
||||
{isSingle ? (
|
||||
// Single-item: the outer chevron already provides disclosure,
|
||||
// so render the underlying content directly instead of wrapping
|
||||
// it in a second collapsible step row.
|
||||
items[0].kind === 'thinking' ? (
|
||||
<ThinkingContent content={items[0].content} />
|
||||
) : (
|
||||
<ToolCallContent toolCall={items[0].toolCall} />
|
||||
)
|
||||
) : (
|
||||
items.map((item, i) => {
|
||||
// A thinking step is live only while it's the trailing item
|
||||
// in a trailing live group — once any later event (text or
|
||||
// tool) arrives, the pass is done.
|
||||
const isLastItem = i === items.length - 1;
|
||||
return item.kind === 'thinking' ? (
|
||||
<ThinkingStep
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
isLive={isLive && isLastItem}
|
||||
/>
|
||||
) : (
|
||||
<ToolCallStep key={item.id} toolCall={item.toolCall} />
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './ActivityGroup';
|
||||
export type { ActivityItem } from './ActivityGroup';
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -9,10 +9,11 @@ import '../blocks';
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { Message, MessageBlock } from '../../types';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import { MessageContext } from '../MessageContext';
|
||||
import MessageFeedback from '../MessageFeedback';
|
||||
import ThinkingStep from '../ThinkingStep';
|
||||
import ToolCallStep from '../ToolCallStep';
|
||||
import UserMessageActions from '../UserMessageActions';
|
||||
|
||||
import styles from './MessageBubble.module.scss';
|
||||
@@ -39,61 +40,38 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
| { kind: 'activity'; id: string; items: ActivityItem[] };
|
||||
|
||||
/**
|
||||
* Partition message blocks into render groups so consecutive thinking and
|
||||
* tool_call blocks collapse into a single ActivityGroup row. Text blocks
|
||||
* stand alone, mirroring the streaming view.
|
||||
*/
|
||||
function groupBlocks(blocks: MessageBlock[]): RenderGroup[] {
|
||||
const groups: RenderGroup[] = [];
|
||||
blocks.forEach((block, i) => {
|
||||
if (block.type === 'text') {
|
||||
groups.push({ kind: 'text', id: `text-${i}`, content: block.content });
|
||||
return;
|
||||
}
|
||||
const item: ActivityItem =
|
||||
block.type === 'thinking'
|
||||
? { id: `t-${i}`, kind: 'thinking', content: block.content }
|
||||
: {
|
||||
id: `c-${block.toolCallId}`,
|
||||
kind: 'tool',
|
||||
// Persisted blocks are always complete.
|
||||
toolCall: {
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
displayText: block.displayText,
|
||||
},
|
||||
};
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.kind === 'activity') {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
groups.push({ kind: 'activity', id: `a-${i}`, items: [item] });
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderGroup(group: RenderGroup): JSX.Element {
|
||||
if (group.kind === 'text') {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={group.id}
|
||||
className={styles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{group.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
/** Renders a single MessageBlock by type. */
|
||||
function renderBlock(block: MessageBlock, index: number): JSX.Element {
|
||||
switch (block.type) {
|
||||
case 'thinking':
|
||||
return <ThinkingStep key={index} content={block.content} />;
|
||||
case 'tool_call':
|
||||
// Blocks in a persisted message are always complete — done is always true.
|
||||
return (
|
||||
<ToolCallStep
|
||||
key={index}
|
||||
toolCall={{
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
displayText: block.displayText,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={index}
|
||||
className={styles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
return <ActivityGroup key={group.id} items={group.items} />;
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -112,14 +90,6 @@ export default function MessageBubble({
|
||||
const isUser = message.role === 'user';
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
|
||||
// Recompute groups only when the blocks array identity changes — store
|
||||
// updates that don't touch this message's blocks should not re-render the
|
||||
// underlying ThinkingStep/ToolCallStep children.
|
||||
const groups = useMemo(
|
||||
() => (hasBlocks ? groupBlocks(message.blocks!) : []),
|
||||
[hasBlocks, message.blocks],
|
||||
);
|
||||
|
||||
const messageClass = cx(
|
||||
styles.message,
|
||||
isUser ? styles.user : styles.assistant,
|
||||
@@ -158,7 +128,8 @@ export default function MessageBubble({
|
||||
<p className={styles.text}>{message.content}</p>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{groups.map((g) => renderGroup(g))}
|
||||
{/* eslint-disable-next-line react/no-array-index-key */}
|
||||
{message.blocks!.map((block, i) => renderBlock(block, i))}
|
||||
</MessageContext.Provider>
|
||||
) : (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -10,10 +10,11 @@ import type {
|
||||
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { StreamingEventItem } from '../../types';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import ApprovalCard from '../ApprovalCard';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import ClarificationForm from '../ClarificationForm';
|
||||
import ThinkingStep from '../ThinkingStep';
|
||||
import ToolCallStep from '../ToolCallStep';
|
||||
|
||||
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
|
||||
import styles from './StreamingMessage.module.scss';
|
||||
@@ -32,59 +33,6 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
| {
|
||||
kind: 'activity';
|
||||
id: string;
|
||||
items: ActivityItem[];
|
||||
isTrailing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Partition the streaming event timeline into render groups: runs of
|
||||
* consecutive thinking/tool events fold into a single activity group, text
|
||||
* events stay standalone. The last group is flagged as trailing so the
|
||||
* caller can drive a "live" indicator on it.
|
||||
*
|
||||
* Invariant relied on by the ActivityGroup elapsed-time timer: once a
|
||||
* group exists at a given array index, later events only extend its
|
||||
* `items` — they never shrink the array or re-key existing groups. That
|
||||
* keeps each ActivityGroup React instance stable across re-renders so the
|
||||
* timer's `wasLive` → `isLive` re-stamp captures the right transition.
|
||||
* The id fields below piggyback on that invariant: each event's position in
|
||||
* `events` is stable, so the derived id stays stable across re-renders.
|
||||
*/
|
||||
function groupStreamingEvents(events: StreamingEventItem[]): RenderGroup[] {
|
||||
const groups: RenderGroup[] = [];
|
||||
events.forEach((event, i) => {
|
||||
if (event.kind === 'text') {
|
||||
groups.push({ kind: 'text', id: `text-${i}`, content: event.content });
|
||||
return;
|
||||
}
|
||||
const item: ActivityItem =
|
||||
event.kind === 'thinking'
|
||||
? { id: `t-${i}`, kind: 'thinking', content: event.content }
|
||||
: { id: `c-${i}`, kind: 'tool', toolCall: event.toolCall };
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.kind === 'activity') {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
groups.push({
|
||||
kind: 'activity',
|
||||
id: `a-${i}`,
|
||||
items: [item],
|
||||
isTrailing: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.kind === 'activity') {
|
||||
last.isTrailing = true;
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Human-readable labels for execution status codes shown before any events arrive. */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
queued: 'Queued…',
|
||||
@@ -131,11 +79,6 @@ export default function StreamingMessage({
|
||||
[messageStyles.compact]: isCompact,
|
||||
});
|
||||
|
||||
// Recompute groups only when the events array identity changes. The
|
||||
// streaming reducer pushes new entries into the same array reference
|
||||
// once per tick, so this naturally invalidates as events arrive.
|
||||
const groups = useMemo(() => groupStreamingEvents(events), [events]);
|
||||
|
||||
return (
|
||||
<div className={messageClass}>
|
||||
<div className={messageStyles.bubble}>
|
||||
@@ -145,28 +88,27 @@ export default function StreamingMessage({
|
||||
)}
|
||||
{isEmpty && !statusLabel && <TypingDots />}
|
||||
|
||||
{/* Runs of consecutive thinking + tool events collapse into a
|
||||
single ActivityGroup; text events render inline between
|
||||
them. The trailing group is "live" while streaming is
|
||||
active and not blocked on the user. */}
|
||||
{groups.map((group) => {
|
||||
if (group.kind === 'text') {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={group.id}
|
||||
className={messageStyles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{group.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
{/* eslint-disable react/no-array-index-key */}
|
||||
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
|
||||
{events.map((event, i) => {
|
||||
if (event.kind === 'tool') {
|
||||
return <ToolCallStep key={i} toolCall={event.toolCall} />;
|
||||
}
|
||||
if (event.kind === 'thinking') {
|
||||
return <ThinkingStep key={i} content={event.content} />;
|
||||
}
|
||||
const groupIsLive = group.isTrailing && !isWaitingOnUser;
|
||||
return (
|
||||
<ActivityGroup key={group.id} items={group.items} isLive={groupIsLive} />
|
||||
<ReactMarkdown
|
||||
key={i}
|
||||
className={messageStyles.markdown}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
})}
|
||||
{/* eslint-enable react/no-array-index-key */}
|
||||
|
||||
{/* While events are still streaming, append the typing dots so the
|
||||
user has a clear "more is coming" signal. Hidden when the agent
|
||||
|
||||
@@ -5,31 +5,11 @@ import styles from './ThinkingStep.module.scss';
|
||||
|
||||
interface ThinkingStepProps {
|
||||
content: string;
|
||||
/**
|
||||
* When false, label reads "Thought for a few seconds" — intentionally
|
||||
* vague because the API doesn't persist precise timing, so showing
|
||||
* seconds would be inconsistent between fresh and reloaded threads.
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
/** Body of a thinking step — extracted so ActivityGroup can render it directly. */
|
||||
export function ThinkingContent({ content }: { content: string }): JSX.Element {
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<p className={styles.content}>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function thinkingLabel(isLive: boolean): string {
|
||||
return isLive ? 'Thinking…' : 'Thought for a few seconds';
|
||||
}
|
||||
|
||||
/** Collapsible thinking row — chevron + label, content in the expanded body. */
|
||||
export default function ThinkingStep({
|
||||
content,
|
||||
isLive = false,
|
||||
}: ThinkingStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -43,10 +23,14 @@ export default function ThinkingStep({
|
||||
) : (
|
||||
<ChevronRight size={12} className={styles.chevron} />
|
||||
)}
|
||||
<span className={styles.label}>{thinkingLabel(isLive)}</span>
|
||||
<span className={styles.label}>Thinking</span>
|
||||
</div>
|
||||
|
||||
{expanded && <ThinkingContent content={content} />}
|
||||
{expanded && (
|
||||
<div className={styles.body}>
|
||||
<p className={styles.content}>{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,58 +10,24 @@ interface ToolCallStepProps {
|
||||
toolCall: StreamingToolCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-supplied `displayText` is the human-friendly title the backend
|
||||
* wants surfaced. Falls back to a derived label
|
||||
* ("signoz_get_dashboard" → "Get Dashboard") when missing.
|
||||
*/
|
||||
export function getToolDisplayLabel(toolCall: StreamingToolCall): string {
|
||||
const { toolName, displayText } = toolCall;
|
||||
if (displayText && displayText.trim().length > 0) {
|
||||
return displayText;
|
||||
}
|
||||
return toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Body of a tool-call step — extracted so ActivityGroup can render it directly. */
|
||||
export function ToolCallContent({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: StreamingToolCall;
|
||||
}): JSX.Element {
|
||||
const { toolName, input, result, done } = toolCall;
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Tool</span>
|
||||
<span className={styles.toolName}>{toolName}</span>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Input</span>
|
||||
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Output</span>
|
||||
<pre className={styles.json}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
|
||||
export default function ToolCallStep({
|
||||
toolCall,
|
||||
}: ToolCallStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { done } = toolCall;
|
||||
const label = getToolDisplayLabel(toolCall);
|
||||
const { toolName, input, result, done, displayText } = toolCall;
|
||||
|
||||
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
|
||||
// it's the human-friendly title the backend wants surfaced. Fall back
|
||||
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
|
||||
// the field is empty / null / missing.
|
||||
const label =
|
||||
displayText && displayText.trim().length > 0
|
||||
? displayText
|
||||
: toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const toggle = (): void => setExpanded((v) => !v);
|
||||
|
||||
@@ -78,7 +44,26 @@ export default function ToolCallStep({
|
||||
<span className={styles.label}>{label}</span>
|
||||
</div>
|
||||
|
||||
{expanded && <ToolCallContent toolCall={toolCall} />}
|
||||
{expanded && (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Tool</span>
|
||||
<span className={styles.toolName}>{toolName}</span>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Input</span>
|
||||
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className={styles.section}>
|
||||
<span className={styles.sectionLabel}>Output</span>
|
||||
<pre className={styles.json}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
@@ -312,6 +313,16 @@ describe('Create Alert Channel (Normal User)', () => {
|
||||
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip('Should check if the upgrade plan message is shown', () => {
|
||||
expect(screen.getByText('Upgrade to a Paid Plan')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/This feature is available for paid plans only./),
|
||||
).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'Click here' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', SIGNOZ_UPGRADE_PLAN_URL);
|
||||
expect(screen.getByText(/to Upgrade/)).toBeInTheDocument();
|
||||
});
|
||||
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'button_save_channel' }),
|
||||
|
||||
@@ -91,6 +91,7 @@ function ChartPreview({
|
||||
const renderQBChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={headline}
|
||||
name=""
|
||||
query={stagedQuery}
|
||||
selectedInterval={globalSelectedInterval}
|
||||
alertDef={alertDef}
|
||||
@@ -106,6 +107,7 @@ function ChartPreview({
|
||||
const renderPromAndChQueryChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={headline}
|
||||
name="Chart Preview"
|
||||
query={stagedQuery}
|
||||
alertDef={alertDef}
|
||||
selectedInterval={globalSelectedInterval}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CreateAlertProvider } from '../../context';
|
||||
import ChartPreview from '../ChartPreview/ChartPreview';
|
||||
|
||||
const REQUESTS_PER_SEC = 'requests/sec';
|
||||
const CHART_PREVIEW_NAME = 'Chart Preview';
|
||||
const QUERY_TYPE_TEST_ID = 'query-type';
|
||||
const GRAPH_TYPE_TEST_ID = 'graph-type';
|
||||
const CHART_PREVIEW_COMPONENT_TEST_ID = 'chart-preview-component';
|
||||
@@ -33,6 +34,7 @@ jest.mock(
|
||||
return (
|
||||
<div data-testid={CHART_PREVIEW_COMPONENT_TEST_ID}>
|
||||
<div data-testid="headline">{props.headline}</div>
|
||||
<div data-testid="name">{props.name}</div>
|
||||
<div data-testid={QUERY_TYPE_TEST_ID}>{props.query?.queryType}</div>
|
||||
<div data-testid="selected-interval">
|
||||
{props.selectedInterval?.startTime}
|
||||
@@ -173,6 +175,12 @@ describe('ChartPreview', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders QueryBuilder chart preview with empty name when query type is QUERY_BUILDER', () => {
|
||||
renderChartPreview();
|
||||
|
||||
expect(screen.getByTestId('name')).toHaveTextContent('');
|
||||
});
|
||||
|
||||
it('renders QueryBuilder chart preview with correct props', () => {
|
||||
renderChartPreview();
|
||||
|
||||
@@ -183,6 +191,7 @@ describe('ChartPreview', () => {
|
||||
expect(screen.getByTestId(GRAPH_TYPE_TEST_ID)).toHaveTextContent(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
expect(screen.getByTestId('name')).toHaveTextContent('');
|
||||
expect(screen.getByTestId('headline')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('selected-interval')).toBeInTheDocument();
|
||||
});
|
||||
@@ -205,6 +214,7 @@ describe('ChartPreview', () => {
|
||||
expect(
|
||||
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
|
||||
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
|
||||
EQueryType.PROM,
|
||||
);
|
||||
@@ -228,6 +238,7 @@ describe('ChartPreview', () => {
|
||||
expect(
|
||||
screen.getByTestId(CHART_PREVIEW_COMPONENT_TEST_ID),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('name')).toHaveTextContent(CHART_PREVIEW_NAME);
|
||||
expect(screen.getByTestId(QUERY_TYPE_TEST_ID)).toHaveTextContent(
|
||||
EQueryType.CLICKHOUSE,
|
||||
);
|
||||
|
||||
@@ -17,11 +17,10 @@ import { getTimeRange } from 'utils/getTimeRange';
|
||||
import BarChart from '../../charts/BarChart/BarChart';
|
||||
import ChartManager from '../../components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig } from './utils';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -100,7 +99,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
if (!queryResponse?.data?.payload) {
|
||||
return [];
|
||||
}
|
||||
return prepareChartData(queryResponse?.data?.payload);
|
||||
return prepareBarPanelData(queryResponse?.data?.payload);
|
||||
}, [queryResponse?.data?.payload]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
|
||||
@@ -11,10 +11,21 @@ import { get } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
|
||||
export function prepareBarPanelData(
|
||||
apiResponse: MetricRangePayloadProps,
|
||||
): AlignedData {
|
||||
const seriesList = apiResponse?.data?.result || [];
|
||||
const timestampArr = getXAxisTimestamps(seriesList);
|
||||
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
|
||||
return [timestampArr, ...yAxisValuesArr];
|
||||
}
|
||||
|
||||
export function prepareBarPanelConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
|
||||
@@ -17,11 +17,10 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
|
||||
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from '../../types';
|
||||
import { prepareUPlotConfig } from '../utils';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { prepareChartData, prepareUPlotConfig } from '../utils';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fillMissingXAxisTimestamps,
|
||||
getXAxisTimestamps,
|
||||
} from 'container/DashboardContainer/visualization/panels/utils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
@@ -11,15 +15,42 @@ import {
|
||||
LineStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import get from 'lodash-es/get';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
|
||||
export const prepareChartData = (
|
||||
apiResponse: MetricRangePayloadProps,
|
||||
): uPlot.AlignedData => {
|
||||
const seriesList = apiResponse?.data?.result || [];
|
||||
const timestampArr = getXAxisTimestamps(seriesList);
|
||||
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
|
||||
|
||||
return [timestampArr, ...yAxisValuesArr];
|
||||
};
|
||||
|
||||
function hasSingleVisiblePointForSeries(series: QueryData): boolean {
|
||||
const rawValues = series.values ?? [];
|
||||
let validPointCount = 0;
|
||||
|
||||
for (const [, rawValue] of rawValues) {
|
||||
if (!isInvalidPlotValue(rawValue)) {
|
||||
validPointCount += 1;
|
||||
if (validPointCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const prepareUPlotConfig = ({
|
||||
widget,
|
||||
isDarkMode,
|
||||
@@ -76,7 +107,7 @@ export const prepareUPlotConfig = ({
|
||||
}
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
|
||||
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.settings-container-root {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 14px 14px 14px 11px;
|
||||
|
||||
.ant-drawer-header-title {
|
||||
gap: 16px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo, PropsWithChildren, ReactElement } from 'react';
|
||||
import { Drawer } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import './SettingsDrawer.styles.scss';
|
||||
|
||||
type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
children,
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsDrawer);
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
|
||||
import DashboardSettingsV2 from '../DashboardSettings';
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardVariablesV2 from '../DashboardVariablesV2';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
|
||||
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
|
||||
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface DashboardDescriptionV2Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
handle: FullScreenHandle;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
|
||||
const { dashboard, handle, onRefetch } = props;
|
||||
|
||||
const id = dashboard?.id ?? '';
|
||||
const isDashboardLocked = !!dashboard?.locked;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const title = dashboard?.data?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.data?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.data?.metadata?.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard?.data?.metadata?.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard?.data?.metadata?.tags],
|
||||
);
|
||||
const dashboardVariables = dashboard?.data?.spec?.variables ?? [];
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard) setUpdatedTitle(title);
|
||||
}, [dashboard, title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
setIsDashbordSettingsOpen(false);
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
notifications.success({ message: 'Dashboard unlocked' });
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
notifications.success({ message: 'Dashboard locked' });
|
||||
}
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
notifications.success({ message: 'Dashboard renamed successfully' });
|
||||
setIsRenameDashboardOpen(false);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
notifications.info({
|
||||
message: 'V2 panel editor coming next',
|
||||
});
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', { ns: 'common' }),
|
||||
});
|
||||
}
|
||||
if (state.value) {
|
||||
notifications.success({ message: t('success', { ns: 'common' }) });
|
||||
}
|
||||
}, [state.error, state.value, t, notifications]);
|
||||
|
||||
const dashboardDataJSON = (): string =>
|
||||
JSON.stringify(dashboard?.data ?? {}, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onConfigureClick = (): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const onSettingsDrawerClose = (): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
|
||||
rootClassName="dashboard-settings"
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
dashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={dashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={dashboard?.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
type="text"
|
||||
className="icons"
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<Plus size="md" />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} className="tag">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
{dashboardVariables.length > 0 && (
|
||||
<section className="dashboard-variables">
|
||||
<DashboardVariablesV2
|
||||
dashboardId={id}
|
||||
variables={dashboardVariables}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
title="Rename Dashboard"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={(): void => {
|
||||
setIsRenameDashboardOpen(false);
|
||||
}}
|
||||
rootClassName="rename-dashboard"
|
||||
footer={
|
||||
<div className="dashboard-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isRenameLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={(): void => setIsRenameDashboardOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dashboard-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescriptionV2;
|
||||
@@ -0,0 +1,227 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
|
||||
// AddTags component (which expects string[]), and back on save.
|
||||
//
|
||||
// V2 tags require both `key` and `value` to be non-empty server-side
|
||||
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
return tagStrings
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx === -1) return { key: trimmed, value: trimmed };
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
return { key, value: value || key };
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
function GeneralDashboardSettingsV2({
|
||||
dashboard,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const id = dashboard?.id ?? '';
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(id);
|
||||
|
||||
const title = dashboard?.data?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.data?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.data?.metadata?.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard?.data?.metadata?.tags ?? []),
|
||||
[dashboard?.data?.metadata?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
|
||||
const [updatedDescription, setUpdatedDescription] = useState<string>(
|
||||
description,
|
||||
);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] = useState<number>(
|
||||
0,
|
||||
);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Sync state when dashboard refetches after a save
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard?.updatedAt]);
|
||||
|
||||
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/metadata/image', updatedImage));
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/metadata/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
notifications.success({ message: 'Dashboard updated' });
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) n += 1;
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
]);
|
||||
|
||||
const discardHandler = (): void => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '21px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '16px 0' }}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
icon={<Check size={14} />}
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralDashboardSettingsV2;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button as ButtonComponent, Drawer } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DrawerContainer = styled(Drawer)`
|
||||
.ant-drawer-header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
|
||||
import { Collapse, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
customValue: string;
|
||||
onChange: (v: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function CustomFields({ customValue, onChange, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variable-custom-section">
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<>
|
||||
<Input.TextArea
|
||||
value={customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
data-testid="variable-custom-value-v2"
|
||||
/>
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomFields;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import DynamicVariable from 'container/DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/DynamicVariable/DynamicVariable';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface Props {
|
||||
dynamicName: string;
|
||||
dynamicSignal: TelemetrytypesSignalDTO | undefined;
|
||||
onNameChange: (v: string) => void;
|
||||
onSignalChange: (v: TelemetrytypesSignalDTO | undefined) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// V1 DynamicVariable stores the source as a UI-friendly label:
|
||||
// 'All telemetry' | 'Logs' | 'Metrics' | 'Traces'. V2 stores the API enum
|
||||
// signal value: undefined (= all) | 'metrics' | 'traces' | 'logs'. We convert
|
||||
// at this boundary so the V1 component can stay untouched.
|
||||
const ALL_TELEMETRY = 'All telemetry';
|
||||
|
||||
function signalToV1Source(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): string {
|
||||
if (signal === TelemetrytypesSignalDTO.logs) return 'Logs';
|
||||
if (signal === TelemetrytypesSignalDTO.metrics) return 'Metrics';
|
||||
if (signal === TelemetrytypesSignalDTO.traces) return 'Traces';
|
||||
return ALL_TELEMETRY;
|
||||
}
|
||||
|
||||
function v1SourceToSignal(
|
||||
source: string,
|
||||
): TelemetrytypesSignalDTO | undefined {
|
||||
if (source === 'Logs') return TelemetrytypesSignalDTO.logs;
|
||||
if (source === 'Metrics') return TelemetrytypesSignalDTO.metrics;
|
||||
if (source === 'Traces') return TelemetrytypesSignalDTO.traces;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function DynamicFields({
|
||||
dynamicName,
|
||||
dynamicSignal,
|
||||
onNameChange,
|
||||
onSignalChange,
|
||||
error,
|
||||
}: Props): JSX.Element {
|
||||
const v1Value = useMemo(
|
||||
() => ({ name: dynamicName, value: signalToV1Source(dynamicSignal) }),
|
||||
[dynamicName, dynamicSignal],
|
||||
);
|
||||
|
||||
const setV1Value: React.Dispatch<
|
||||
React.SetStateAction<{ name: string; value: string } | undefined>
|
||||
> = useCallback(
|
||||
(action) => {
|
||||
const next =
|
||||
typeof action === 'function' ? action(v1Value) : action;
|
||||
if (!next) return;
|
||||
if (next.name !== dynamicName) onNameChange(next.name);
|
||||
const nextSignal = v1SourceToSignal(next.value);
|
||||
if (nextSignal !== dynamicSignal) onSignalChange(nextSignal);
|
||||
},
|
||||
[v1Value, dynamicName, dynamicSignal, onNameChange, onSignalChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variable-dynamic-section">
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={setV1Value}
|
||||
dynamicVariablesSelectedValue={v1Value}
|
||||
errorAttributeKeyMessage={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicFields;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from 'antd';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
saving: boolean;
|
||||
canSave: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function Footer({ saving, canSave, onSave, onCancel }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="variable-item-footer">
|
||||
<VariableItemRow>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={onCancel}
|
||||
icon={<X size={14} />}
|
||||
className="footer-btn-discard"
|
||||
disabled={saving}
|
||||
data-testid="variable-cancel-v2"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onSave}
|
||||
icon={<Check size={14} />}
|
||||
className="footer-btn-save"
|
||||
loading={saving}
|
||||
disabled={!canSave || saving}
|
||||
data-testid="variable-save-v2"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</VariableItemRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { V2VariableKind } from '../types';
|
||||
import AllOptionRow from './ListOptions/AllOptionRow';
|
||||
import CapturingRegexpRow from './ListOptions/CapturingRegexpRow';
|
||||
import CustomAllValueRow from './ListOptions/CustomAllValueRow';
|
||||
import DefaultValueRow from './ListOptions/DefaultValueRow';
|
||||
import MultiSelectRow from './ListOptions/MultiSelectRow';
|
||||
import SortRow from './ListOptions/SortRow';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: string;
|
||||
defaultValue: string;
|
||||
customAllValue: string;
|
||||
capturingRegexp: string;
|
||||
previewValues: string[];
|
||||
onAllowAllChange: (v: boolean) => void;
|
||||
onAllowMultipleChange: (v: boolean) => void;
|
||||
onSortChange: (v: string) => void;
|
||||
onDefaultValueChange: (v: string) => void;
|
||||
onCustomAllValueChange: (v: string) => void;
|
||||
onCapturingRegexpChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function ListBasicOptions({
|
||||
kind,
|
||||
allowAllValue,
|
||||
allowMultiple,
|
||||
sort,
|
||||
defaultValue,
|
||||
customAllValue,
|
||||
capturingRegexp,
|
||||
previewValues,
|
||||
onAllowAllChange,
|
||||
onAllowMultipleChange,
|
||||
onSortChange,
|
||||
onDefaultValueChange,
|
||||
onCustomAllValueChange,
|
||||
onCapturingRegexpChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SortRow sort={sort} onChange={onSortChange} />
|
||||
<MultiSelectRow
|
||||
allowMultiple={allowMultiple}
|
||||
onChange={(v): void => {
|
||||
onAllowMultipleChange(v);
|
||||
if (!v) onAllowAllChange(false);
|
||||
}}
|
||||
/>
|
||||
{allowMultiple && kind !== 'DYNAMIC' ? (
|
||||
<AllOptionRow
|
||||
allowAllValue={allowAllValue}
|
||||
onChange={onAllowAllChange}
|
||||
/>
|
||||
) : null}
|
||||
{allowAllValue ? (
|
||||
<CustomAllValueRow
|
||||
customAllValue={customAllValue}
|
||||
onChange={onCustomAllValueChange}
|
||||
/>
|
||||
) : null}
|
||||
{kind === 'QUERY' || kind === 'DYNAMIC' ? (
|
||||
<CapturingRegexpRow
|
||||
capturingRegexp={capturingRegexp}
|
||||
onChange={onCapturingRegexpChange}
|
||||
/>
|
||||
) : null}
|
||||
<DefaultValueRow
|
||||
kind={kind}
|
||||
defaultValue={defaultValue}
|
||||
previewValues={previewValues}
|
||||
onChange={onDefaultValueChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListBasicOptions;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Switch } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
allowAllValue: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function AllOptionRow({ allowAllValue, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="all-option-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Include an option for ALL values
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={allowAllValue}
|
||||
onChange={onChange}
|
||||
data-testid="variable-allow-all-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default AllOptionRow;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
capturingRegexp: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CapturingRegexpRow({
|
||||
capturingRegexp,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="capturing-regexp-section">
|
||||
<LabelContainer>
|
||||
<Typography
|
||||
className="typography-variables"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Capturing regex
|
||||
</Typography>
|
||||
<Typography
|
||||
className="default-value-description"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Regex applied to each value; the first capture group becomes the
|
||||
selectable option.
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Input
|
||||
value={capturingRegexp}
|
||||
placeholder="e.g. env-(.*)-\\d+"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-capturing-regexp-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CapturingRegexpRow;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
customAllValue: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CustomAllValueRow({
|
||||
customAllValue,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="custom-all-value-section">
|
||||
<LabelContainer>
|
||||
<Typography
|
||||
className="typography-variables"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Custom "ALL" value
|
||||
</Typography>
|
||||
<Typography
|
||||
className="default-value-description"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
Literal value emitted when the user picks ALL (e.g. * or .*).
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Input
|
||||
value={customAllValue}
|
||||
placeholder="Leave blank to send the full union of values"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-custom-all-value-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomAllValueRow;
|
||||
@@ -0,0 +1,43 @@
|
||||
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import type { V2VariableKind } from '../../types';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
defaultValue: string;
|
||||
previewValues: string[];
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function DefaultValueRow({
|
||||
kind,
|
||||
defaultValue,
|
||||
previewValues,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
const description =
|
||||
kind === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value';
|
||||
|
||||
return (
|
||||
<VariableItemRow className="default-value-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
<Typography className="default-value-description">
|
||||
{description}
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<CustomSelect
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue}
|
||||
onChange={(v): void => onChange((v as string) ?? '')}
|
||||
options={previewValues.map((v) => ({ label: v, value: v }))}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultValueRow;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Switch } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
allowMultiple: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function MultiSelectRow({ allowMultiple, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="multiple-values-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">
|
||||
Enable multiple values to be checked
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<Switch
|
||||
checked={allowMultiple}
|
||||
onChange={onChange}
|
||||
data-testid="variable-allow-multiple-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectRow;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import { SORT_OPTIONS } from '../../types';
|
||||
|
||||
interface Props {
|
||||
sort: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function SortRow({ sort, onChange }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="sort-values-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Sort Values</Typography>
|
||||
</LabelContainer>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={onChange}
|
||||
options={SORT_OPTIONS}
|
||||
className="sort-input"
|
||||
data-testid="variable-sort-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortRow;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description: string;
|
||||
onNameChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
nameError?: string;
|
||||
}
|
||||
|
||||
function NameDisplay({
|
||||
name,
|
||||
description,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
nameError,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<VariableItemRow className="variable-name-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Name</Typography>
|
||||
</LabelContainer>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Unique name of the variable"
|
||||
value={name}
|
||||
className="name-input"
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
data-testid="variable-name-v2"
|
||||
/>
|
||||
{nameError ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{nameError}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
<VariableItemRow className="variable-description-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Description</Typography>
|
||||
</LabelContainer>
|
||||
<Input.TextArea
|
||||
value={description}
|
||||
placeholder="Enter a description for the variable"
|
||||
className="description-input"
|
||||
rows={3}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
data-testid="variable-description-v2"
|
||||
/>
|
||||
</VariableItemRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NameDisplay;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { orange } from '@ant-design/colors';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
previewValues: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function PreviewValues({ previewValues, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variables-preview-section">
|
||||
<LabelContainer style={{ width: '100%' }}>
|
||||
<Typography className="typography-variables">
|
||||
Preview of Values
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<div className="preview-values">
|
||||
{error ? (
|
||||
<Typography style={{ color: orange[5] }}>{error}</Typography>
|
||||
) : (
|
||||
previewValues.map((v, idx) => (
|
||||
<Tag key={`${v}${idx}`}>{v.toString()}</Tag>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewValues;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Editor from 'components/Editor';
|
||||
|
||||
import { LabelContainer } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
queryValue: string;
|
||||
onChange: (v: string) => void;
|
||||
onTestRun?: () => void;
|
||||
testRunLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function QueryFields({
|
||||
queryValue,
|
||||
onChange,
|
||||
onTestRun,
|
||||
testRunLoading,
|
||||
error,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="query-container">
|
||||
<LabelContainer>
|
||||
<Typography>Query</Typography>
|
||||
</LabelContainer>
|
||||
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Editor
|
||||
language="sql"
|
||||
value={queryValue}
|
||||
onChange={onChange}
|
||||
height="240px"
|
||||
options={{
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'off',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 0,
|
||||
minimap: { enabled: false },
|
||||
}}
|
||||
/>
|
||||
{onTestRun ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={onTestRun}
|
||||
style={{ position: 'absolute', bottom: 0 }}
|
||||
loading={testRunLoading}
|
||||
>
|
||||
Test Run Query
|
||||
</Button>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryFields;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
|
||||
interface Props {
|
||||
textValue: string;
|
||||
onChange: (v: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function TextFields({ textValue, onChange, error }: Props): JSX.Element {
|
||||
return (
|
||||
<VariableItemRow className="variable-textbox-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
</LabelContainer>
|
||||
<div>
|
||||
<Input
|
||||
value={textValue}
|
||||
className="default-input"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
style={{ width: 400 }}
|
||||
data-testid="variable-text-value-v2"
|
||||
/>
|
||||
{error ? (
|
||||
<div>
|
||||
<Typography.Text color="warning">{error}</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextFields;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Button, Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
|
||||
import type { V2VariableKind } from '../types';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
|
||||
|
||||
interface Props {
|
||||
kind: V2VariableKind;
|
||||
onChange: (kind: V2VariableKind) => void;
|
||||
}
|
||||
|
||||
function TypeSelector({ kind, onChange }: Props): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<VariableItemRow className="variable-type-section">
|
||||
<LabelContainer className="variable-type-label-container">
|
||||
<Typography className="typography-variables">Variable Type</Typography>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</LabelContainer>
|
||||
|
||||
<div className="variable-type-btn-group">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Pyramid size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'DYNAMIC' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
data-testid="variable-type-dynamic-v2"
|
||||
>
|
||||
Dynamic
|
||||
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
|
||||
Beta
|
||||
</Tag>
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardType size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'TEXT' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
data-testid="variable-type-text-v2"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutList size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'CUSTOM' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
data-testid="variable-type-custom-v2"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DatabaseZap size={14} />}
|
||||
className={cx(
|
||||
'variable-type-btn',
|
||||
kind === 'QUERY' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
data-testid="variable-type-query-v2"
|
||||
>
|
||||
Query
|
||||
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
|
||||
Not Recommended
|
||||
</Tag>
|
||||
<div onClick={(e): void => e.stopPropagation()}>
|
||||
<TextToolTip
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TypeSelector;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { draftToVariableDTO, validateDraft } from '../draft';
|
||||
import type { SaveCallback, VariableDraft, V2VariableKind } from '../types';
|
||||
import CustomFields from './CustomFields';
|
||||
import DynamicFields from './DynamicFields';
|
||||
import Footer from './Footer';
|
||||
import ListBasicOptions from './ListBasicOptions';
|
||||
import NameDisplay from './NameDisplay';
|
||||
import PreviewValues from './PreviewValues';
|
||||
import QueryFields from './QueryFields';
|
||||
import TextFields from './TextFields';
|
||||
import TypeSelector from './TypeSelector';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
|
||||
|
||||
interface Props {
|
||||
initialDraft: VariableDraft;
|
||||
existingNames: string[];
|
||||
saving: boolean;
|
||||
onSave: SaveCallback;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor for a single V2 variable.
|
||||
*
|
||||
* Type-switch contract: changing `kind` does NOT clear the per-kind fields
|
||||
* the user already typed. They remain in local state and are restored if the
|
||||
* user navigates back to the same kind. Only the fields relevant to the
|
||||
* active `kind` are written into the V2 envelope on save (see
|
||||
* `draftToVariableDTO`).
|
||||
*/
|
||||
function VariableItem({
|
||||
initialDraft,
|
||||
existingNames,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<VariableDraft>(initialDraft);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof VariableDraft>(key: K, value: VariableDraft[K]): void => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onKindChange = useCallback(
|
||||
(kind: V2VariableKind): void => {
|
||||
// Retain every other field — only the discriminator changes.
|
||||
update('kind', kind);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
const namesExcludingSelf = useMemo(
|
||||
() => existingNames.filter((n) => n !== initialDraft.name),
|
||||
[existingNames, initialDraft.name],
|
||||
);
|
||||
const validationError = useMemo(
|
||||
() => validateDraft(draft, namesExcludingSelf),
|
||||
[draft, namesExcludingSelf],
|
||||
);
|
||||
|
||||
// Local preview values — currently populated only for CUSTOM (CSV parse).
|
||||
// Query / Dynamic previews are wired in the variable execution subsystem.
|
||||
const previewValues = useMemo<string[]>(() => {
|
||||
if (draft.kind === 'CUSTOM') {
|
||||
return commaValuesParser(draft.customValue).map((v) => String(v));
|
||||
}
|
||||
return [];
|
||||
}, [draft.kind, draft.customValue]);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
if (validationError) return;
|
||||
onSave(draftToVariableDTO(draft));
|
||||
}, [draft, validationError, onSave]);
|
||||
|
||||
const errorFor = (
|
||||
field: NonNullable<typeof validationError>['field'],
|
||||
): string | undefined => {
|
||||
if (validationError && validationError.field === field) {
|
||||
return validationError.message;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const showListOptions =
|
||||
draft.kind === 'QUERY' || draft.kind === 'CUSTOM' || draft.kind === 'DYNAMIC';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="variable-item-container">
|
||||
<div className="all-variables">
|
||||
<Button
|
||||
type="text"
|
||||
className="all-variables-btn"
|
||||
icon={<ArrowLeft size={14} />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
<div className="variable-item-content">
|
||||
<NameDisplay
|
||||
name={draft.name}
|
||||
description={draft.displayName}
|
||||
onNameChange={(v): void => update('name', v)}
|
||||
onDescriptionChange={(v): void => update('displayName', v)}
|
||||
nameError={errorFor('name')}
|
||||
/>
|
||||
|
||||
<TypeSelector kind={draft.kind} onChange={onKindChange} />
|
||||
|
||||
{draft.kind === 'DYNAMIC' ? (
|
||||
<DynamicFields
|
||||
dynamicName={draft.dynamicName}
|
||||
dynamicSignal={draft.dynamicSignal}
|
||||
onNameChange={(v): void => update('dynamicName', v)}
|
||||
onSignalChange={(v): void => update('dynamicSignal', v)}
|
||||
error={errorFor('dynamicName')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'QUERY' ? (
|
||||
<QueryFields
|
||||
queryValue={draft.queryValue}
|
||||
onChange={(v): void => update('queryValue', v)}
|
||||
error={errorFor('queryValue')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'CUSTOM' ? (
|
||||
<CustomFields
|
||||
customValue={draft.customValue}
|
||||
onChange={(v): void => update('customValue', v)}
|
||||
error={errorFor('customValue')}
|
||||
/>
|
||||
) : null}
|
||||
{draft.kind === 'TEXT' ? (
|
||||
<TextFields
|
||||
textValue={draft.textValue}
|
||||
onChange={(v): void => update('textValue', v)}
|
||||
error={errorFor('textValue')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showListOptions ? (
|
||||
<>
|
||||
<PreviewValues previewValues={previewValues} />
|
||||
<ListBasicOptions
|
||||
kind={draft.kind}
|
||||
allowAllValue={draft.allowAllValue}
|
||||
allowMultiple={draft.allowMultiple}
|
||||
sort={draft.sort}
|
||||
defaultValue={draft.defaultValue}
|
||||
customAllValue={draft.customAllValue}
|
||||
capturingRegexp={draft.capturingRegexp}
|
||||
previewValues={previewValues}
|
||||
onAllowAllChange={(v): void => update('allowAllValue', v)}
|
||||
onAllowMultipleChange={(v): void => update('allowMultiple', v)}
|
||||
onSortChange={(v): void => update('sort', v)}
|
||||
onDefaultValueChange={(v): void => update('defaultValue', v)}
|
||||
onCustomAllValueChange={(v): void =>
|
||||
update('customAllValue', v)
|
||||
}
|
||||
onCapturingRegexpChange={(v): void =>
|
||||
update('capturingRegexp', v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
saving={saving}
|
||||
canSave={!validationError}
|
||||
onSave={handleSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableItem;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import type { RowProps } from 'antd';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
|
||||
/**
|
||||
* Sortable table row that injects a drag handle into the `name` cell —
|
||||
* matches V1's [DashboardVariableSettings/index.tsx:31](TableRow component).
|
||||
*/
|
||||
function TableRow({ children, ...props }: RowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
// @ts-expect-error — antd Table's RowProps doesn't type the data-row-key it injects
|
||||
id: props['data-row-key'],
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...props.style,
|
||||
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
|
||||
{React.Children.map(children, (child) => {
|
||||
const childElement = child as React.ReactElement;
|
||||
if (childElement.key === 'name') {
|
||||
return React.cloneElement(childElement, {
|
||||
key: 'name-with-drag',
|
||||
children: (
|
||||
<div className="variable-name-drag">
|
||||
<GripVertical
|
||||
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
|
||||
style={{ touchAction: 'none', cursor: 'move' }}
|
||||
size="md"
|
||||
{...listeners}
|
||||
/>
|
||||
{child}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
return childElement;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableRow;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Space, Tag } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
kindLabel: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right cell of the variable table — description text + edit/delete actions.
|
||||
* Variable name + kind tag render in the left cell via column config.
|
||||
*/
|
||||
function VariableRow({
|
||||
description,
|
||||
kindLabel,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="variable-description-actions">
|
||||
<Typography.Text className="variable-description">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
<Space className="actions-btns">
|
||||
<Tag>{kindLabel}</Tag>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onEdit}
|
||||
className="edit-variable-button"
|
||||
data-testid="variable-edit-v2"
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onDelete}
|
||||
className="delete-variable-button"
|
||||
data-testid="variable-delete-v2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableRow;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Empty, Table } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
|
||||
import type { DashboardtypesVariableDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getVariableKindLabel, getVariableName } from '../draft';
|
||||
import TableRow from './TableRow';
|
||||
import VariableRow from './VariableRow';
|
||||
|
||||
import '../../../../DashboardContainer/DashboardSettings/DashboardSettings.styles.scss';
|
||||
|
||||
interface TableEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kindLabel: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variables: DashboardtypesVariableDTO[];
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onReorder: (next: DashboardtypesVariableDTO[]) => void;
|
||||
}
|
||||
|
||||
function VariableList({
|
||||
variables,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: Props): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>
|
||||
No variables yet. Click "Add variable" to create one.
|
||||
</Typography.Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dataSource: TableEntry[] = variables.map((v, idx) => ({
|
||||
key: getVariableName(v) || String(idx),
|
||||
name: getVariableName(v),
|
||||
description:
|
||||
(v.spec as { display?: { name?: string } })?.display?.name ?? '',
|
||||
kindLabel: getVariableKindLabel(v),
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Variable',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
key: 'description',
|
||||
width: '50%',
|
||||
render: (entry: TableEntry): JSX.Element => (
|
||||
<VariableRow
|
||||
description={entry.description}
|
||||
kindLabel={entry.kindLabel}
|
||||
onEdit={(): void => onEdit(entry.index)}
|
||||
onDelete={(): void => onDelete(entry.index)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const onDragEnd = ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) return;
|
||||
const fromIdx = dataSource.findIndex((d) => d.key === active.id);
|
||||
const toIdx = dataSource.findIndex((d) => d.key === over.id);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
onReorder(arrayMove(variables, fromIdx, toIdx));
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<SortableContext items={dataSource.map((d) => d.key)}>
|
||||
<Table
|
||||
components={{ body: { row: TableRow } }}
|
||||
rowKey="key"
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
dataSource={dataSource}
|
||||
className="dashboard-variable-settings-table"
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableList;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import type {
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { V2VariableKind, VariableDraft } from './types';
|
||||
|
||||
export function emptyDraft(): VariableDraft {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
kind: 'QUERY',
|
||||
name: '',
|
||||
displayName: '',
|
||||
allowAllValue: false,
|
||||
allowMultiple: false,
|
||||
sort: 'none',
|
||||
defaultValue: '',
|
||||
customAllValue: '',
|
||||
capturingRegexp: '',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
dynamicName: '',
|
||||
dynamicSignal: undefined,
|
||||
textValue: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the relevant slot from a V2 envelope; other slots stay empty.
|
||||
*/
|
||||
export function variableDTOToDraft(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableDraft {
|
||||
const base = emptyDraft();
|
||||
if (dto.kind === 'TextVariable') {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
return {
|
||||
...base,
|
||||
kind: 'TEXT',
|
||||
name: spec?.name ?? '',
|
||||
displayName: spec?.display?.name ?? '',
|
||||
textValue: spec?.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// ListVariable
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
let kind: V2VariableKind = 'QUERY';
|
||||
if (pluginKind === 'signoz/DynamicVariable') kind = 'DYNAMIC';
|
||||
else if (pluginKind === 'signoz/CustomVariable') kind = 'CUSTOM';
|
||||
else if (pluginKind === 'signoz/QueryVariable') kind = 'QUERY';
|
||||
|
||||
const draft: VariableDraft = {
|
||||
...base,
|
||||
kind,
|
||||
name: spec?.name ?? '',
|
||||
displayName: spec?.display?.name ?? '',
|
||||
allowAllValue: !!spec?.allowAllValue,
|
||||
allowMultiple: !!spec?.allowMultiple,
|
||||
sort: spec?.sort ?? 'none',
|
||||
defaultValue: typeof spec?.defaultValue === 'string' ? spec.defaultValue : '',
|
||||
customAllValue: spec?.customAllValue ?? '',
|
||||
capturingRegexp: spec?.capturingRegexp ?? '',
|
||||
};
|
||||
|
||||
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
|
||||
if (kind === 'QUERY') {
|
||||
draft.queryValue = (pluginSpec?.queryValue as string) ?? '';
|
||||
} else if (kind === 'CUSTOM') {
|
||||
draft.customValue = (pluginSpec?.customValue as string) ?? '';
|
||||
} else if (kind === 'DYNAMIC') {
|
||||
draft.dynamicName = (pluginSpec?.name as string) ?? '';
|
||||
draft.dynamicSignal = pluginSpec?.signal as TelemetrytypesSignalDTO | undefined;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize draft to a V2 envelope, reading ONLY the fields relevant to the
|
||||
* active kind. Other fields the user touched stay in React state and are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function draftToVariableDTO(
|
||||
draft: VariableDraft,
|
||||
): DashboardtypesVariableDTO {
|
||||
const display = draft.displayName ? { name: draft.displayName } : undefined;
|
||||
|
||||
if (draft.kind === 'TEXT') {
|
||||
return ({
|
||||
kind: 'TextVariable',
|
||||
spec: {
|
||||
name: draft.name,
|
||||
display,
|
||||
value: draft.textValue,
|
||||
},
|
||||
} as unknown) as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
let plugin: DashboardtypesVariablePluginDTO | undefined;
|
||||
if (draft.kind === 'QUERY') {
|
||||
plugin = ({
|
||||
kind: 'signoz/QueryVariable',
|
||||
spec: { queryValue: draft.queryValue },
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
} else if (draft.kind === 'CUSTOM') {
|
||||
plugin = ({
|
||||
kind: 'signoz/CustomVariable',
|
||||
spec: { customValue: draft.customValue },
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
} else if (draft.kind === 'DYNAMIC') {
|
||||
plugin = ({
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: {
|
||||
name: draft.dynamicName,
|
||||
signal: draft.dynamicSignal,
|
||||
},
|
||||
} as unknown) as DashboardtypesVariablePluginDTO;
|
||||
}
|
||||
|
||||
const spec: DashboardtypesListVariableSpecDTO = {
|
||||
name: draft.name,
|
||||
display,
|
||||
allowAllValue: draft.allowAllValue,
|
||||
allowMultiple: draft.allowMultiple,
|
||||
sort: draft.sort,
|
||||
plugin,
|
||||
// VariableDefaultValueDTO is an open `{[key]: unknown}` shape, so a bare
|
||||
// string isn't structurally assignable. We cast at the boundary.
|
||||
defaultValue: draft.defaultValue
|
||||
? ((draft.defaultValue as unknown) as DashboardtypesListVariableSpecDTO['defaultValue'])
|
||||
: undefined,
|
||||
customAllValue: draft.customAllValue || undefined,
|
||||
capturingRegexp: draft.capturingRegexp || undefined,
|
||||
};
|
||||
|
||||
return ({
|
||||
kind: 'ListVariable',
|
||||
spec,
|
||||
} as unknown) as DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
export interface DraftValidationError {
|
||||
field:
|
||||
| 'name'
|
||||
| 'queryValue'
|
||||
| 'customValue'
|
||||
| 'dynamicName'
|
||||
| 'textValue'
|
||||
| 'cycle';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function validateDraft(
|
||||
draft: VariableDraft,
|
||||
existingNames: string[],
|
||||
): DraftValidationError | null {
|
||||
const trimmedName = draft.name.trim();
|
||||
if (!trimmedName) {
|
||||
return { field: 'name', message: 'Variable name is required' };
|
||||
}
|
||||
if (/\s/.test(trimmedName)) {
|
||||
return { field: 'name', message: 'Variable name cannot contain whitespace' };
|
||||
}
|
||||
if (existingNames.includes(trimmedName)) {
|
||||
return { field: 'name', message: 'Variable name already exists' };
|
||||
}
|
||||
|
||||
if (draft.kind === 'QUERY' && !draft.queryValue.trim()) {
|
||||
return { field: 'queryValue', message: 'Query is required' };
|
||||
}
|
||||
if (draft.kind === 'CUSTOM' && !draft.customValue.trim()) {
|
||||
return { field: 'customValue', message: 'Custom values are required' };
|
||||
}
|
||||
if (draft.kind === 'DYNAMIC' && !draft.dynamicName.trim()) {
|
||||
return { field: 'dynamicName', message: 'Attribute name is required' };
|
||||
}
|
||||
if (draft.kind === 'TEXT' && !draft.textValue.trim()) {
|
||||
return { field: 'textValue', message: 'Default text value is required' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getVariableName(dto: DashboardtypesVariableDTO): string {
|
||||
if (dto.kind === 'TextVariable') {
|
||||
return (dto.spec as DashboardTextVariableSpecDTO)?.name ?? '';
|
||||
}
|
||||
return (dto.spec as DashboardtypesListVariableSpecDTO)?.name ?? '';
|
||||
}
|
||||
|
||||
export function getVariableKindLabel(dto: DashboardtypesVariableDTO): string {
|
||||
if (dto.kind === 'TextVariable') return 'Text';
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
if (pluginKind === 'signoz/DynamicVariable') return 'Dynamic';
|
||||
if (pluginKind === 'signoz/CustomVariable') return 'Custom';
|
||||
return 'Query';
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
buildDependencyMap,
|
||||
detectCycle,
|
||||
} from '../../DashboardVariablesV2/dependencyGraph';
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
import {
|
||||
emptyDraft,
|
||||
getVariableName,
|
||||
variableDTOToDraft,
|
||||
} from './draft';
|
||||
import type { VariableDraft } from './types';
|
||||
import VariableItem from './VariableItem';
|
||||
import VariableList from './VariableList';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
type EditorState =
|
||||
| { kind: 'closed' }
|
||||
| { kind: 'add'; draft: VariableDraft }
|
||||
| { kind: 'edit'; index: number; draft: VariableDraft };
|
||||
|
||||
function VariablesSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const dashboardId = dashboard?.id ?? '';
|
||||
const variables = useMemo<DashboardtypesVariableDTO[]>(
|
||||
() => dashboard?.data?.spec?.variables ?? [],
|
||||
[dashboard?.data?.spec?.variables],
|
||||
);
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ kind: 'closed' });
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const existingNames = useMemo(() => variables.map(getVariableName), [
|
||||
variables,
|
||||
]);
|
||||
|
||||
const persistVariables = useCallback(
|
||||
async (next: DashboardtypesVariableDTO[]): Promise<void> => {
|
||||
if (!dashboardId) return;
|
||||
const cycle = detectCycle(buildDependencyMap(next));
|
||||
if (cycle.hasCycle) {
|
||||
notifications.error({
|
||||
message: `Cyclic variable dependency: ${cycle.cycle?.join(' → ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/variables',
|
||||
value: next,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id: dashboardId }, patch);
|
||||
notifications.success({ message: 'Variables updated' });
|
||||
onRefetch();
|
||||
setEditor({ kind: 'closed' });
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, notifications, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (dto: DashboardtypesVariableDTO): Promise<void> => {
|
||||
if (editor.kind === 'add') {
|
||||
await persistVariables([...variables, dto]);
|
||||
} else if (editor.kind === 'edit') {
|
||||
const next = variables.slice();
|
||||
next[editor.index] = dto;
|
||||
await persistVariables(next);
|
||||
}
|
||||
},
|
||||
[editor, variables, persistVariables],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (index: number): Promise<void> => {
|
||||
const next = variables.slice();
|
||||
next.splice(index, 1);
|
||||
await persistVariables(next);
|
||||
},
|
||||
[variables, persistVariables],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{editor.kind === 'closed' ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={(): void =>
|
||||
setEditor({ kind: 'add', draft: emptyDraft() })
|
||||
}
|
||||
data-testid="add-variable-v2"
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
<VariableList
|
||||
variables={variables}
|
||||
onEdit={(index): void =>
|
||||
setEditor({
|
||||
kind: 'edit',
|
||||
index,
|
||||
draft: variableDTOToDraft(variables[index]),
|
||||
})
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
onReorder={persistVariables}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<VariableItem
|
||||
initialDraft={editor.draft}
|
||||
existingNames={existingNames}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onCancel={(): void => setEditor({ kind: 'closed' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesSettingsV2;
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
DashboardtypesVariableDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type V2VariableKind = 'QUERY' | 'CUSTOM' | 'DYNAMIC' | 'TEXT';
|
||||
|
||||
/**
|
||||
* Internal editor state. Holds every per-kind field so that switching `kind`
|
||||
* does not discard user input. Only the fields relevant to the active kind
|
||||
* are written into the resulting V2 envelope on save.
|
||||
*/
|
||||
export interface VariableDraft {
|
||||
id: string; // local identifier for list keys; not persisted to V2
|
||||
kind: V2VariableKind;
|
||||
name: string;
|
||||
displayName: string;
|
||||
|
||||
// Shared by all List variants (QUERY / CUSTOM / DYNAMIC)
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: string;
|
||||
defaultValue: string;
|
||||
// V2-only: literal value emitted when the user picks "ALL"
|
||||
customAllValue: string;
|
||||
// V2-only: regex applied to query/dynamic results to extract the actual value
|
||||
capturingRegexp: string;
|
||||
|
||||
// QUERY
|
||||
queryValue: string;
|
||||
|
||||
// CUSTOM
|
||||
customValue: string;
|
||||
|
||||
// DYNAMIC
|
||||
dynamicName: string;
|
||||
dynamicSignal: TelemetrytypesSignalDTO | undefined;
|
||||
|
||||
// TEXT
|
||||
textValue: string;
|
||||
}
|
||||
|
||||
export type SaveCallback = (dto: DashboardtypesVariableDTO) => void;
|
||||
|
||||
export const VARIABLE_KIND_LABEL: Record<V2VariableKind, string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
DYNAMIC: 'Dynamic',
|
||||
TEXT: 'Text',
|
||||
};
|
||||
|
||||
// V2 supports a finer sort taxonomy than V1: separate alphabetical and
|
||||
// numerical orderings (V1 only exposed Disabled / Ascending / Descending).
|
||||
// Values match the strings used in the perses fixture and backend.
|
||||
export const SORT_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Disabled', value: 'none' },
|
||||
{ label: 'Alphabetical ascending', value: 'alphabetical-asc' },
|
||||
{ label: 'Alphabetical descending', value: 'alphabetical-desc' },
|
||||
{ label: 'Numerical ascending', value: 'numerical-asc' },
|
||||
{ label: 'Numerical descending', value: 'numerical-desc' },
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button, Empty, Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
|
||||
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
|
||||
|
||||
import GeneralDashboardSettingsV2 from './General';
|
||||
import VariablesSettingsV2 from './Variables';
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function Placeholder({ message }: { message: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />}>
|
||||
General
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
children: (
|
||||
<GeneralDashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />}>
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
children: (
|
||||
<VariablesSettingsV2 dashboard={dashboard} onRefetch={onRefetch} />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Globe size={14} />}>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: (
|
||||
<Placeholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettingsV2;
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { referencedVariables } from './substitution';
|
||||
|
||||
/**
|
||||
* Extracts the strings on a variable that may contain `$var` references —
|
||||
* i.e. the dependency edges out of this variable.
|
||||
*
|
||||
* Currently only QUERY variables produce dependencies (their `queryValue`
|
||||
* may reference other variables). CUSTOM and DYNAMIC plugin specs don't
|
||||
* embed substitutable strings, and TEXT variables are leaf nodes.
|
||||
*/
|
||||
function dependencyStrings(dto: DashboardtypesVariableDTO): string[] {
|
||||
if (dto.kind !== 'ListVariable') return [];
|
||||
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
|
||||
const pluginKind = spec?.plugin?.kind;
|
||||
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
|
||||
if (pluginKind === 'signoz/QueryVariable') {
|
||||
return [String(pluginSpec?.queryValue ?? '')];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function nameOf(dto: DashboardtypesVariableDTO): string {
|
||||
return (dto.spec as { name?: string })?.name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct dependencies for each variable (name → set of names it references).
|
||||
*/
|
||||
export function buildDependencyMap(
|
||||
variables: DashboardtypesVariableDTO[],
|
||||
): Record<string, Set<string>> {
|
||||
const knownNames = new Set(variables.map(nameOf).filter(Boolean));
|
||||
const deps: Record<string, Set<string>> = {};
|
||||
variables.forEach((v) => {
|
||||
const name = nameOf(v);
|
||||
if (!name) return;
|
||||
const refs = new Set<string>();
|
||||
dependencyStrings(v).forEach((s) => {
|
||||
referencedVariables(s).forEach((ref) => {
|
||||
if (ref !== name && knownNames.has(ref)) refs.add(ref);
|
||||
});
|
||||
});
|
||||
deps[name] = refs;
|
||||
});
|
||||
return deps;
|
||||
}
|
||||
|
||||
export interface CycleResult {
|
||||
hasCycle: boolean;
|
||||
cycle?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a cycle via DFS; returns the participating names in traversal order.
|
||||
* Used at save time and to guard re-resolution.
|
||||
*/
|
||||
export function detectCycle(
|
||||
deps: Record<string, Set<string>>,
|
||||
): CycleResult {
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color: Record<string, number> = {};
|
||||
const stack: string[] = [];
|
||||
const names = Object.keys(deps);
|
||||
names.forEach((n) => {
|
||||
color[n] = WHITE;
|
||||
});
|
||||
|
||||
function visit(node: string): string[] | null {
|
||||
color[node] = GRAY;
|
||||
stack.push(node);
|
||||
for (const next of deps[node] ?? []) {
|
||||
if (color[next] === GRAY) {
|
||||
const idx = stack.indexOf(next);
|
||||
return stack.slice(idx).concat(next);
|
||||
}
|
||||
if (color[next] === WHITE) {
|
||||
const found = visit(next);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
color[node] = BLACK;
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
if (color[n] === WHITE) {
|
||||
const cycle = visit(n);
|
||||
if (cycle) return { hasCycle: true, cycle };
|
||||
}
|
||||
}
|
||||
return { hasCycle: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kahn's algorithm — returns variable names in dependency order
|
||||
* (dependencies first). If there's a cycle the result excludes the
|
||||
* participating nodes; combine with `detectCycle` for validation.
|
||||
*/
|
||||
export function topoSort(
|
||||
deps: Record<string, Set<string>>,
|
||||
): string[] {
|
||||
const incoming: Record<string, number> = {};
|
||||
const downstream: Record<string, string[]> = {};
|
||||
Object.keys(deps).forEach((n) => {
|
||||
incoming[n] = 0;
|
||||
downstream[n] = [];
|
||||
});
|
||||
Object.entries(deps).forEach(([n, refs]) => {
|
||||
refs.forEach((ref) => {
|
||||
incoming[n] += 1;
|
||||
downstream[ref] = downstream[ref] ?? [];
|
||||
downstream[ref].push(n);
|
||||
});
|
||||
});
|
||||
|
||||
const queue: string[] = Object.keys(incoming).filter((n) => incoming[n] === 0);
|
||||
const out: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const n = queue.shift() as string;
|
||||
out.push(n);
|
||||
(downstream[n] ?? []).forEach((next) => {
|
||||
incoming[next] -= 1;
|
||||
if (incoming[next] === 0) queue.push(next);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { buildDependencyMap, detectCycle, topoSort } from './dependencyGraph';
|
||||
import VariableSelector from './selectors/VariableSelector';
|
||||
import { useVariableSelectionStore } from './state/selectionStore';
|
||||
|
||||
import '../../DashboardContainer/DashboardVariablesSelection/DashboardVariableSelection.styles.scss';
|
||||
|
||||
interface Props {
|
||||
dashboardId: string;
|
||||
variables: DashboardtypesVariableDTO[] | undefined;
|
||||
}
|
||||
|
||||
function nameOf(v: DashboardtypesVariableDTO): string {
|
||||
return (
|
||||
(v.spec as DashboardtypesListVariableSpecDTO | DashboardTextVariableSpecDTO)
|
||||
?.name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function kindHint(v: DashboardtypesVariableDTO): 'list' | 'text' {
|
||||
return v.kind === 'TextVariable' ? 'text' : 'list';
|
||||
}
|
||||
|
||||
function DashboardVariablesV2({ dashboardId, variables }: Props): JSX.Element | null {
|
||||
const hydrate = useVariableSelectionStore((s) => s.hydrate);
|
||||
|
||||
// Build hints map (variable-name → list/text) so the store can decode the URL.
|
||||
const hints = useMemo<Record<string, 'list' | 'text'>>(() => {
|
||||
const out: Record<string, 'list' | 'text'> = {};
|
||||
(variables ?? []).forEach((v) => {
|
||||
const n = nameOf(v);
|
||||
if (n) out[n] = kindHint(v);
|
||||
});
|
||||
return out;
|
||||
}, [variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
hydrate(dashboardId, hints);
|
||||
}, [dashboardId, hints, hydrate]);
|
||||
|
||||
// Sort variables in dependency order so dependent resolvers see fresh
|
||||
// selections from their parents. (Render order doesn't affect the React
|
||||
// Query cache but it does affect *visual* order.)
|
||||
const ordered = useMemo(() => {
|
||||
if (!variables?.length) return [];
|
||||
const deps = buildDependencyMap(variables);
|
||||
const cycle = detectCycle(deps);
|
||||
if (cycle.hasCycle) {
|
||||
// Render in the original order; the cycle is surfaced separately at save
|
||||
// time via validateDraft. Resolution will still execute; it just won't
|
||||
// converge.
|
||||
return variables;
|
||||
}
|
||||
const order = topoSort(deps);
|
||||
const byName: Record<string, DashboardtypesVariableDTO> = {};
|
||||
variables.forEach((v) => {
|
||||
const n = nameOf(v);
|
||||
if (n) byName[n] = v;
|
||||
});
|
||||
return order.map((n) => byName[n]).filter(Boolean);
|
||||
}, [variables]);
|
||||
|
||||
if (!variables || variables.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="variables-container">
|
||||
{ordered.map((v) => (
|
||||
<VariableSelector key={nameOf(v)} variable={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardVariablesV2;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Applies V2 `capturingRegexp` to each value: if the regex matches and has a
|
||||
* capture group, replace the value with the first capture; otherwise keep
|
||||
* the raw value. Invalid regex silently passes values through.
|
||||
*
|
||||
* Empty results (no match at all) are filtered out — they would be useless
|
||||
* as selectable options.
|
||||
*/
|
||||
export function applyCapturingRegexp(
|
||||
values: string[],
|
||||
pattern: string | undefined | null,
|
||||
): string[] {
|
||||
if (!pattern) return values;
|
||||
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern);
|
||||
} catch {
|
||||
return values;
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
values.forEach((v) => {
|
||||
const m = re.exec(v);
|
||||
if (!m) return;
|
||||
out.push(m[1] !== undefined ? m[1] : m[0]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Apply V2 sort modes to a resolved value list.
|
||||
*
|
||||
* Sort values come from the perses spec — `none`, `alphabetical-asc`,
|
||||
* `alphabetical-desc`, `numerical-asc`, `numerical-desc`. Numerical sort
|
||||
* falls back to string compare for values that aren't numbers so we never
|
||||
* throw away non-numeric entries.
|
||||
*/
|
||||
export function applySort(
|
||||
values: string[],
|
||||
sort: string | null | undefined,
|
||||
): string[] {
|
||||
if (!sort || sort === 'none' || values.length <= 1) return values;
|
||||
const copy = values.slice();
|
||||
if (sort === 'alphabetical-asc') {
|
||||
copy.sort((a, b) => a.localeCompare(b));
|
||||
} else if (sort === 'alphabetical-desc') {
|
||||
copy.sort((a, b) => b.localeCompare(a));
|
||||
} else if (sort === 'numerical-asc' || sort === 'numerical-desc') {
|
||||
copy.sort((a, b) => {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
const aFinite = Number.isFinite(na);
|
||||
const bFinite = Number.isFinite(nb);
|
||||
if (aFinite && bFinite) {
|
||||
return sort === 'numerical-asc' ? na - nb : nb - na;
|
||||
}
|
||||
// Mixed numeric/non-numeric: keep non-numerics at the end, sorted alpha.
|
||||
if (aFinite) return -1;
|
||||
if (bFinite) return 1;
|
||||
return sort === 'numerical-asc'
|
||||
? a.localeCompare(b)
|
||||
: b.localeCompare(a);
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Output of resolving a single list variable. Text variables don't go
|
||||
* through resolution — their value is the literal string.
|
||||
*/
|
||||
export interface ResolvedValues {
|
||||
values: string[];
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const idle: ResolvedValues = { values: [], status: 'idle' };
|
||||
export const loading: ResolvedValues = { values: [], status: 'loading' };
|
||||
export function success(values: string[]): ResolvedValues {
|
||||
return { values, status: 'success' };
|
||||
}
|
||||
export function failure(error: string): ResolvedValues {
|
||||
return { values: [], status: 'error', error };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { success, type ResolvedValues } from './types';
|
||||
|
||||
/**
|
||||
* CUSTOM variables: the comma-separated user input is the value list.
|
||||
* No network call, purely client-side.
|
||||
*/
|
||||
export function useCustomResolver(customValue: string): ResolvedValues {
|
||||
return useMemo(
|
||||
() => success(commaValuesParser(customValue).map((v) => String(v))),
|
||||
[customValue],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { failure, idle, loading, success, type ResolvedValues } from './types';
|
||||
|
||||
function signalToV1(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): 'traces' | 'logs' | 'metrics' | undefined {
|
||||
if (signal === TelemetrytypesSignalDTO.traces) return 'traces';
|
||||
if (signal === TelemetrytypesSignalDTO.logs) return 'logs';
|
||||
if (signal === TelemetrytypesSignalDTO.metrics) return 'metrics';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* DYNAMIC variables: telemetry attribute lookup.
|
||||
* - `signal === undefined` → search across all telemetry types.
|
||||
* - Otherwise scoped to the specific signal.
|
||||
*
|
||||
* Uses the existing V1 hook directly; the API is V2-shape-agnostic.
|
||||
*/
|
||||
export function useDynamicResolver(
|
||||
attributeName: string,
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): ResolvedValues {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const enabled = !!attributeName;
|
||||
const { data, isLoading, isError, error } = useGetFieldValues({
|
||||
signal: signalToV1(signal),
|
||||
name: attributeName,
|
||||
enabled,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
});
|
||||
|
||||
if (!enabled) return idle;
|
||||
if (isLoading) return loading;
|
||||
if (isError) {
|
||||
return failure(
|
||||
(error as Error)?.message ?? 'Failed to resolve dynamic variable',
|
||||
);
|
||||
}
|
||||
return success(data?.data?.normalizedValues ?? []);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import { substituteVariables } from '../substitution';
|
||||
import type { SelectionsByName } from '../state/types';
|
||||
import { failure, idle, loading, success, type ResolvedValues } from './types';
|
||||
|
||||
/**
|
||||
* Reduce the user's V2 selections to the V1 `PayloadVariables` shape the
|
||||
* variables/query endpoint expects (a plain name → selected-value map).
|
||||
*/
|
||||
function selectionsToPayload(
|
||||
selections: SelectionsByName,
|
||||
): PayloadVariables {
|
||||
const out: PayloadVariables = {};
|
||||
Object.entries(selections).forEach(([name, sel]) => {
|
||||
if (!sel) return;
|
||||
if (sel.kind === 'text') {
|
||||
out[name] = sel.value;
|
||||
} else if (sel.allSelected) {
|
||||
// Endpoint understands `__ALL__`-style markers via the substitution
|
||||
// done client-side; leave the value out so server doesn't double up.
|
||||
// (Callers using IN ($var) expand via substituteVariables instead.)
|
||||
} else if (sel.values.length === 1) {
|
||||
out[name] = sel.values[0];
|
||||
} else {
|
||||
out[name] = sel.values;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
interface UseQueryResolverArgs {
|
||||
variableName: string;
|
||||
queryValue: string;
|
||||
selections: SelectionsByName;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUERY variables: substitute `$var` references using current selections,
|
||||
* then POST to `/api/v2/variables/query`. React Query caches per
|
||||
* (name, substitutedQuery) so re-render with the same inputs reuses results.
|
||||
*/
|
||||
export function useQueryResolver({
|
||||
variableName,
|
||||
queryValue,
|
||||
selections,
|
||||
enabled,
|
||||
}: UseQueryResolverArgs): ResolvedValues {
|
||||
const substituted = substituteVariables(queryValue, selections);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['v2-variable-query', variableName, substituted],
|
||||
queryFn: () =>
|
||||
dashboardVariablesQuery({
|
||||
query: substituted,
|
||||
variables: selectionsToPayload(selections),
|
||||
}),
|
||||
enabled: enabled && !!substituted,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!enabled || !substituted) return idle;
|
||||
if (isLoading) return loading;
|
||||
if (isError) {
|
||||
return failure(
|
||||
(error as { details?: { error?: string } })?.details?.error ??
|
||||
(error as Error)?.message ??
|
||||
'Variable query failed',
|
||||
);
|
||||
}
|
||||
const payload = (data as { payload?: { variableValues?: unknown[] } } | undefined)
|
||||
?.payload;
|
||||
const values = (payload?.variableValues ?? []).map((v) => String(v));
|
||||
return success(values);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useVariableSelectionStore } from '../state/selectionStore';
|
||||
import { applyCapturingRegexp } from './capturingRegexp';
|
||||
import { applySort } from './sorting';
|
||||
import { useCustomResolver } from './useCustomResolver';
|
||||
import { useDynamicResolver } from './useDynamicResolver';
|
||||
import { useQueryResolver } from './useQueryResolver';
|
||||
import { idle, success, type ResolvedValues } from './types';
|
||||
|
||||
interface UseResolveVariableArgs {
|
||||
variable: DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a variable to the correct resolver hook and applies the V2
|
||||
* post-processing pipeline:
|
||||
*
|
||||
* raw values → capturingRegexp → sort → final list
|
||||
*
|
||||
* Text variables short-circuit since they don't have a value list.
|
||||
*/
|
||||
export function useResolveVariable({
|
||||
variable,
|
||||
}: UseResolveVariableArgs): ResolvedValues {
|
||||
const selections = useVariableSelectionStore((s) => s.selections);
|
||||
|
||||
// Read all fields up front so the React Query / hook order is stable
|
||||
// across renders (hooks must not be called conditionally).
|
||||
const isText = variable.kind === 'TextVariable';
|
||||
const listSpec = (variable.spec as DashboardtypesListVariableSpecDTO) ?? {};
|
||||
const pluginKind = listSpec.plugin?.kind;
|
||||
const pluginSpec = (listSpec.plugin?.spec as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
const name = listSpec?.name ?? '';
|
||||
const customValue = (pluginSpec.customValue as string) ?? '';
|
||||
const queryValue = (pluginSpec.queryValue as string) ?? '';
|
||||
const dynName = (pluginSpec.name as string) ?? '';
|
||||
const dynSignal = pluginSpec.signal as TelemetrytypesSignalDTO | undefined;
|
||||
|
||||
const customRes = useCustomResolver(
|
||||
pluginKind === 'signoz/CustomVariable' ? customValue : '',
|
||||
);
|
||||
const dynRes = useDynamicResolver(
|
||||
pluginKind === 'signoz/DynamicVariable' ? dynName : '',
|
||||
dynSignal,
|
||||
);
|
||||
const queryRes = useQueryResolver({
|
||||
variableName: name,
|
||||
queryValue: pluginKind === 'signoz/QueryVariable' ? queryValue : '',
|
||||
selections,
|
||||
enabled: pluginKind === 'signoz/QueryVariable',
|
||||
});
|
||||
|
||||
const raw: ResolvedValues = useMemo(() => {
|
||||
if (isText) return success([]);
|
||||
if (pluginKind === 'signoz/CustomVariable') return customRes;
|
||||
if (pluginKind === 'signoz/DynamicVariable') return dynRes;
|
||||
if (pluginKind === 'signoz/QueryVariable') return queryRes;
|
||||
return idle;
|
||||
}, [isText, pluginKind, customRes, dynRes, queryRes]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (raw.status !== 'success') return raw;
|
||||
const afterRegex = applyCapturingRegexp(raw.values, listSpec.capturingRegexp);
|
||||
const afterSort = applySort(afterRegex, listSpec.sort);
|
||||
return success(afterSort);
|
||||
}, [raw, listSpec.capturingRegexp, listSpec.sort]);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import SelectVariableInput from 'container/DashboardContainer/DashboardVariablesSelection/SelectVariableInput';
|
||||
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
|
||||
|
||||
import type { ResolvedValues } from '../resolution/types';
|
||||
import type { VariableSelection } from '../state/types';
|
||||
|
||||
interface Props {
|
||||
variableId: string;
|
||||
resolved: ResolvedValues;
|
||||
selection: VariableSelection | undefined;
|
||||
allowMultiple: boolean;
|
||||
allowAllValue: boolean;
|
||||
defaultValue: string;
|
||||
onChange: (selection: VariableSelection) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function selectionToValue(
|
||||
selection: VariableSelection | undefined,
|
||||
defaultValue: string,
|
||||
allowMultiple: boolean,
|
||||
): string | string[] | undefined {
|
||||
if (selection && selection.kind === 'list') {
|
||||
if (selection.allSelected) return ALL_SELECT_VALUE;
|
||||
if (allowMultiple) return selection.values;
|
||||
return selection.values[0];
|
||||
}
|
||||
if (defaultValue) return allowMultiple ? [defaultValue] : defaultValue;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* QUERY / CUSTOM / DYNAMIC variables share the same dropdown UX: a list of
|
||||
* options + an optional ALL entry + single / multi-select. Reuses V1's
|
||||
* `SelectVariableInput` so visuals match exactly.
|
||||
*/
|
||||
function ListVariableSelector({
|
||||
variableId,
|
||||
resolved,
|
||||
selection,
|
||||
allowMultiple,
|
||||
allowAllValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onClear,
|
||||
}: Props): JSX.Element {
|
||||
const options = useMemo(
|
||||
() => resolved.values.map((v) => ({ label: v, value: v })),
|
||||
[resolved.values],
|
||||
);
|
||||
|
||||
const value = selectionToValue(selection, defaultValue, allowMultiple);
|
||||
|
||||
return (
|
||||
<SelectVariableInput
|
||||
variableId={variableId}
|
||||
options={options}
|
||||
value={value}
|
||||
enableSelectAll={allowAllValue}
|
||||
isMultiSelect={allowMultiple}
|
||||
loading={resolved.status === 'loading'}
|
||||
errorMessage={resolved.error ?? null}
|
||||
onChange={(next): void => {
|
||||
if (Array.isArray(next)) {
|
||||
// Multi-select. Antd's CustomMultiSelect emits the ALL sentinel
|
||||
// when the user toggles the "Select all" row.
|
||||
const hasAll = next.includes(ALL_SELECT_VALUE);
|
||||
onChange({
|
||||
kind: 'list',
|
||||
values: hasAll ? [] : next,
|
||||
allSelected: hasAll,
|
||||
});
|
||||
} else if (next === ALL_SELECT_VALUE) {
|
||||
onChange({ kind: 'list', values: [], allSelected: true });
|
||||
} else {
|
||||
onChange({
|
||||
kind: 'list',
|
||||
values: next ? [next] : [],
|
||||
allSelected: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClear={onClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListVariableSelector;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SolidInfoCircle } from '@signozhq/icons';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V1-style label: `$name` + an info tooltip if a description is set.
|
||||
* Mirrors [DashboardVariablesSelection/VariableItem.tsx:34-42](V1).
|
||||
*/
|
||||
function SelectorLabel({ name, description }: Props): JSX.Element {
|
||||
return (
|
||||
<Typography.Text className="variable-name" truncate={1}>
|
||||
${name}
|
||||
{description ? (
|
||||
<Tooltip title={description}>
|
||||
<SolidInfoCircle className="info-icon" size="md" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectorLabel;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text variable input — commits on blur (and on Enter), matching V1's
|
||||
* `TextboxVariableInput` UX which avoids re-fetching panels on every
|
||||
* keystroke.
|
||||
*/
|
||||
function TextVariableSelector({ value, onCommit }: Props): JSX.Element {
|
||||
const [draft, setDraft] = useState<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
const commit = (): void => {
|
||||
if (draft !== value) onCommit(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="variable-select"
|
||||
value={draft}
|
||||
onChange={(e): void => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onPressEnter={commit}
|
||||
data-testid="text-variable-input-v2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextVariableSelector;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useResolveVariable } from '../resolution/useResolveVariable';
|
||||
import { useVariableSelectionStore } from '../state/selectionStore';
|
||||
import type { VariableSelection } from '../state/types';
|
||||
import ListVariableSelector from './ListVariableSelector';
|
||||
import SelectorLabel from './SelectorLabel';
|
||||
import TextVariableSelector from './TextVariableSelector';
|
||||
|
||||
interface Props {
|
||||
variable: DashboardtypesVariableDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes one variable to its kind-specific selector. Owns the selection
|
||||
* store binding so the kind-specific components stay dumb.
|
||||
*/
|
||||
function VariableSelector({ variable }: Props): JSX.Element | null {
|
||||
const isText = variable.kind === 'TextVariable';
|
||||
const spec = variable.spec as
|
||||
| DashboardtypesListVariableSpecDTO
|
||||
| DashboardTextVariableSpecDTO
|
||||
| undefined;
|
||||
const name = spec?.name ?? '';
|
||||
|
||||
const selection = useVariableSelectionStore((s) =>
|
||||
name ? s.selections[name] : undefined,
|
||||
);
|
||||
const setSelection = useVariableSelectionStore((s) => s.setSelection);
|
||||
const resolved = useResolveVariable({ variable });
|
||||
|
||||
const setListSelection = useCallback(
|
||||
(next: VariableSelection): void => setSelection(name, next),
|
||||
[name, setSelection],
|
||||
);
|
||||
const clearSelection = useCallback((): void => setSelection(name, undefined), [
|
||||
name,
|
||||
setSelection,
|
||||
]);
|
||||
|
||||
if (!name) return null;
|
||||
|
||||
const description = spec?.display?.name ?? '';
|
||||
|
||||
if (isText) {
|
||||
const textSpec = spec as DashboardTextVariableSpecDTO;
|
||||
const current =
|
||||
selection?.kind === 'text' ? selection.value : textSpec?.value ?? '';
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<SelectorLabel name={name} description={description} />
|
||||
<div className="variable-value">
|
||||
<TextVariableSelector
|
||||
value={current}
|
||||
onCommit={(v): void => setSelection(name, { kind: 'text', value: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const listSpec = spec as DashboardtypesListVariableSpecDTO;
|
||||
const defaultValue =
|
||||
typeof listSpec?.defaultValue === 'string'
|
||||
? (listSpec.defaultValue as string)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<SelectorLabel name={name} description={description} />
|
||||
<div className="variable-value">
|
||||
<ListVariableSelector
|
||||
variableId={name}
|
||||
resolved={resolved}
|
||||
selection={selection}
|
||||
allowMultiple={!!listSpec?.allowMultiple}
|
||||
allowAllValue={!!listSpec?.allowAllValue}
|
||||
defaultValue={defaultValue}
|
||||
onChange={setListSelection}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableSelector;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SelectionsByName } from './types';
|
||||
|
||||
const STORAGE_PREFIX = 'dashboard-v2-variables';
|
||||
|
||||
function storageKey(dashboardId: string): string {
|
||||
return `${STORAGE_PREFIX}:${dashboardId}`;
|
||||
}
|
||||
|
||||
export function loadSelectionsFromStorage(
|
||||
dashboardId: string,
|
||||
): SelectionsByName {
|
||||
if (!dashboardId) return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(dashboardId));
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as SelectionsByName;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSelectionsToStorage(
|
||||
dashboardId: string,
|
||||
selections: SelectionsByName,
|
||||
): void {
|
||||
if (!dashboardId) return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKey(dashboardId),
|
||||
JSON.stringify(selections),
|
||||
);
|
||||
} catch {
|
||||
// quota / availability issues — selection still lives in memory + URL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
loadSelectionsFromStorage,
|
||||
saveSelectionsToStorage,
|
||||
} from './localStorage';
|
||||
import type { SelectionsByName, VariableSelection } from './types';
|
||||
import { readSelectionsFromUrl, writeSelectionsToUrl } from './urlSync';
|
||||
|
||||
interface SelectionStoreState {
|
||||
dashboardId: string;
|
||||
selections: SelectionsByName;
|
||||
|
||||
/**
|
||||
* Hydrate from URL → fallback to LocalStorage. Called once per dashboard
|
||||
* load. `hints` lets URL decoding pick list vs text encoding.
|
||||
*/
|
||||
hydrate: (
|
||||
dashboardId: string,
|
||||
hints: Record<string, 'list' | 'text'>,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Set / clear the selection for a single variable. Persists to both
|
||||
* LocalStorage and URL.
|
||||
*/
|
||||
setSelection: (name: string, selection: VariableSelection | undefined) => void;
|
||||
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useVariableSelectionStore = create<SelectionStoreState>(
|
||||
(set, get) => ({
|
||||
dashboardId: '',
|
||||
selections: {},
|
||||
|
||||
hydrate: (dashboardId, hints): void => {
|
||||
const fromUrl = readSelectionsFromUrl(hints);
|
||||
const fromStorage = loadSelectionsFromStorage(dashboardId);
|
||||
// URL wins over LocalStorage (shareable links override personal
|
||||
// preferences).
|
||||
const merged: SelectionsByName = { ...fromStorage, ...fromUrl };
|
||||
set({ dashboardId, selections: merged });
|
||||
},
|
||||
|
||||
setSelection: (name, selection): void => {
|
||||
const { dashboardId, selections } = get();
|
||||
const next: SelectionsByName = { ...selections };
|
||||
if (selection === undefined) {
|
||||
delete next[name];
|
||||
} else {
|
||||
next[name] = selection;
|
||||
}
|
||||
set({ selections: next });
|
||||
saveSelectionsToStorage(dashboardId, next);
|
||||
writeSelectionsToUrl(next);
|
||||
},
|
||||
|
||||
reset: (): void => {
|
||||
set({ dashboardId: '', selections: {} });
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A single variable's selected value.
|
||||
*
|
||||
* - `kind: 'list'` is used for QUERY / CUSTOM / DYNAMIC list variables.
|
||||
* - `allSelected: true` represents the user picking "ALL"; `values` is
|
||||
* ignored in that case.
|
||||
* - `values` is an array even for single-select to keep the shape uniform;
|
||||
* single-select uses index 0.
|
||||
* - `kind: 'text'` is the TextVariable case: one freeform string.
|
||||
*/
|
||||
export type VariableSelection =
|
||||
| { kind: 'list'; values: string[]; allSelected: boolean }
|
||||
| { kind: 'text'; value: string };
|
||||
|
||||
/**
|
||||
* Map of `variable name` → selection. Per dashboard, in memory + persisted.
|
||||
*/
|
||||
export type SelectionsByName = Record<string, VariableSelection | undefined>;
|
||||
|
||||
export const ALL_SENTINEL = '__ALL__';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ALL_SENTINEL, type SelectionsByName, type VariableSelection } from './types';
|
||||
|
||||
const URL_PREFIX = 'var-';
|
||||
|
||||
/**
|
||||
* Encodes a single selection into a URL-safe string. Compact format:
|
||||
* - text variable → the freeform string
|
||||
* - list (ALL) → "__ALL__"
|
||||
* - list (single) → "value"
|
||||
* - list (multi) → "v1,v2,v3"
|
||||
*/
|
||||
function encodeSelection(sel: VariableSelection): string {
|
||||
if (sel.kind === 'text') return sel.value;
|
||||
if (sel.allSelected) return ALL_SENTINEL;
|
||||
return sel.values.join(',');
|
||||
}
|
||||
|
||||
function decodeSelection(
|
||||
raw: string,
|
||||
hint: 'list' | 'text',
|
||||
): VariableSelection {
|
||||
if (hint === 'text') return { kind: 'text', value: raw };
|
||||
if (raw === ALL_SENTINEL) {
|
||||
return { kind: 'list', values: [], allSelected: true };
|
||||
}
|
||||
const values = raw ? raw.split(',') : [];
|
||||
return { kind: 'list', values, allSelected: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `var-<name>=<encoded>` params off the current location.
|
||||
* `hints` tells us each variable's kind (list vs text) for decoding.
|
||||
*/
|
||||
export function readSelectionsFromUrl(
|
||||
hints: Record<string, 'list' | 'text'>,
|
||||
): SelectionsByName {
|
||||
const out: SelectionsByName = {};
|
||||
if (typeof window === 'undefined') return out;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.forEach((value, key) => {
|
||||
if (!key.startsWith(URL_PREFIX)) return;
|
||||
const name = key.slice(URL_PREFIX.length);
|
||||
const hint = hints[name];
|
||||
if (!hint) return;
|
||||
out[name] = decodeSelection(value, hint);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current selections into the URL, replacing any previous
|
||||
* `var-*` params. Uses `replaceState` so it doesn't pollute history.
|
||||
*/
|
||||
export function writeSelectionsToUrl(selections: SelectionsByName): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Strip existing var-* params
|
||||
const keysToDelete: string[] = [];
|
||||
params.forEach((_, key) => {
|
||||
if (key.startsWith(URL_PREFIX)) keysToDelete.push(key);
|
||||
});
|
||||
keysToDelete.forEach((k) => params.delete(k));
|
||||
|
||||
Object.entries(selections).forEach(([name, sel]) => {
|
||||
if (!sel) return;
|
||||
params.set(`${URL_PREFIX}${name}`, encodeSelection(sel));
|
||||
});
|
||||
|
||||
const search = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`;
|
||||
window.history.replaceState(window.history.state, '', nextUrl);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ALL_SENTINEL, type SelectionsByName } from './state/types';
|
||||
|
||||
/**
|
||||
* Replaces `$varname` references in a string with the current selection.
|
||||
*
|
||||
* - text selection → the freeform string
|
||||
* - list, allSelected → ALL_SENTINEL (callers decide whether to expand to
|
||||
* all known values or to send the literal marker)
|
||||
* - list, single value → that value
|
||||
* - list, multi values → comma-joined; brackets if caller wraps with IN ()
|
||||
*
|
||||
* Variable names match `[a-zA-Z_][a-zA-Z0-9_.]*` so dotted attribute keys
|
||||
* like `$service.name` work. Substitution is non-recursive (we don't expand
|
||||
* `$other` if a value happens to contain another reference).
|
||||
*/
|
||||
const VARIABLE_REF = /\$([a-zA-Z_][a-zA-Z0-9_.]*)/g;
|
||||
|
||||
function selectionToString(
|
||||
selection: SelectionsByName[string],
|
||||
): string | null {
|
||||
if (!selection) return null;
|
||||
if (selection.kind === 'text') return selection.value;
|
||||
if (selection.allSelected) return ALL_SENTINEL;
|
||||
if (selection.values.length === 0) return '';
|
||||
return selection.values.join(',');
|
||||
}
|
||||
|
||||
export function substituteVariables(
|
||||
template: string,
|
||||
selections: SelectionsByName,
|
||||
): string {
|
||||
if (!template) return template;
|
||||
return template.replace(VARIABLE_REF, (match, name: string) => {
|
||||
const sel = selections[name];
|
||||
const value = selectionToString(sel);
|
||||
// Leave unresolved references intact so the consumer can decide how to
|
||||
// handle them (better than producing silent partial substitutions).
|
||||
return value === null ? match : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the variable names referenced in a string. Used by the dependency
|
||||
* graph (Phase 5).
|
||||
*/
|
||||
export function referencedVariables(template: string): string[] {
|
||||
if (!template) return [];
|
||||
const out = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(VARIABLE_REF.source, 'g');
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = re.exec(template)) !== null) {
|
||||
out.add(match[1]);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface Props {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
}
|
||||
|
||||
function PanelV2({ panel, panelId }: Props): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) return name;
|
||||
return (
|
||||
<Tooltip title={description}>
|
||||
<span>{name}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
background: 'var(--bg-ink-400, #0b0c0e)',
|
||||
border: '1px solid var(--bg-slate-400, #1d212d)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="drag-handle"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--bg-slate-400, #1d212d)',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{kind}</Tag>
|
||||
</div>
|
||||
<EllipsisVertical size={14} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
color: 'var(--bg-vanilla-400, #8993ae)',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6 }}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelV2;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import type { DashboardSectionV2 } from '../utils';
|
||||
import PanelV2 from './PanelV2';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
}
|
||||
|
||||
function SectionGrid({ items }: { items: DashboardSectionV2['items'] }): JSX.Element {
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".drag-handle"
|
||||
isDraggable={false}
|
||||
isResizable={false}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<PanelV2 panel={item.panel} panelId={item.id} />
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ section }: Props): JSX.Element {
|
||||
// Local toggle override — initial state from layout spec; user can
|
||||
// expand/collapse without persisting.
|
||||
const [open, setOpen] = useState<boolean>(section.open);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — render just the grid (no header chrome).
|
||||
return <SectionGrid items={section.items} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
border: '1px solid var(--bg-slate-500)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={(): void => setOpen((v) => !v)}
|
||||
icon={open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '8px 12px',
|
||||
borderBottom: open ? '1px solid var(--bg-slate-500)' : 'none',
|
||||
}}
|
||||
data-testid={`dashboard-section-toggle-${section.id}`}
|
||||
>
|
||||
<Typography.Text style={{ marginLeft: 4 }}>
|
||||
{section.title}
|
||||
</Typography.Text>
|
||||
{section.repeatVariable ? (
|
||||
<Typography.Text style={{ marginLeft: 8, opacity: 0.6 }}>
|
||||
(repeats per ${section.repeatVariable})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Button>
|
||||
{open ? <SectionGrid items={section.items} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user