Compare commits

..

55 Commits

Author SHA1 Message Date
nikhilmantri0902
5fa6bd8b8d Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-13 11:02:14 +05:30
nikhilmantri0902
bd9977483b chore: improved description 2026-04-11 11:31:35 +05:30
nikhilmantri0902
50fbdfeeef chore: validate order by to validate function 2026-04-10 19:01:45 +05:30
swapnil-signoz
7279c5f770 feat: adding query params in cloud integration APIs (#10900)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat: adding query params in cloud integration APIs

* refactor: create account HTTP status change from OK to CREATED
2026-04-10 09:20:35 +00:00
nikhilmantri0902
e2b1b73e87 chore: improvements 2026-04-10 13:23:33 +05:30
nikhilmantri0902
cb9f3fd3e5 chore: rearrage 2026-04-10 00:39:23 +05:30
nikhilmantri0902
232acc343d chore: escape backtick to prevent sql injection 2026-04-10 00:01:01 +05:30
nikhilmantri0902
2025afdccc chore: endpoint modification openapi 2026-04-09 23:25:59 +05:30
nikhilmantri0902
d2f4d4af93 chore: endpoint correction 2026-04-09 23:21:57 +05:30
Nikhil Mantri
47ff7bbb8e Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-09 23:20:39 +05:30
Nikhil Soni
e543776efc chore: send obfuscate query in the clickhouse query panel update (#10848)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: send query in the clickhouse query panel update

* chore: obfuscate query to avoid sending sensitive values
2026-04-09 14:15:10 +00:00
Nikhil Mantri
724071c5dc Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-09 18:30:15 +05:30
nikhilmantri0902
4d24979358 chore: frontend fix 2026-04-09 18:26:42 +05:30
nikhilmantri0902
042943b10a chore: distributed samples table to local table change for get metadata 2026-04-09 18:24:45 +05:30
nikhilmantri0902
48a9be7ec8 chore: added required metrics check 2026-04-09 17:38:48 +05:30
Pandey
621127b7fb feat(audit): wire auditor into DI graph and service lifecycle (#10891)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(audit): wire auditor into DI graph and service lifecycle

Register the auditor in the factory service registry so it participates
in application lifecycle (start/stop/health). Community uses noopauditor,
enterprise uses otlphttpauditor with licensing gate. Pass the auditor
instance to the audit middleware instead of nil.

* feat(audit): use NamedMap provider pattern with config-driven selection

Switch from single-factory callback to NamedMap + factory.NewProviderFromNamedMap
so the config's Provider field selects the auditor implementation. Add
NewAuditorProviderFactories() with noop as the community default. Enterprise
extends the map with otlphttpauditor. Add auditor section to conf/example.yaml
and set default provider to "noop" in config.

* chore: move auditor config to end of example.yaml
2026-04-09 11:44:05 +00:00
nikhilmantri0902
a9504b2120 chore: added a TODO remark 2026-04-09 16:08:34 +05:30
nikhilmantri0902
8755887c4a chore: added better metrics existence check 2026-04-09 16:01:35 +05:30
Nikhil Mantri
4cb4662b3a Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-09 15:14:25 +05:30
Pandey
0648cd4e18 feat(audit): add telemetry audit query infrastructure (#10811)
* feat(audit): add telemetry audit query infrastructure

Add pkg/telemetryaudit/ with tables, field mapper, condition builder,
and statement builder for querying audit logs from signoz_audit database.
Add SourceAudit to source enum and integrate audit key resolution
into the metadata store.

* chore: address review comments

Comment out SourceAudit from Enum() until frontend is ready.
Use actual audit table constants in metadata test helpers.

* fix(audit): align field mapper with actual audit DDL schema

Remove resources_string (not in audit table DDL).
Add event_name as intrinsic column.
Resource context resolves only through the resource JSON column.

* feat(audit): add audit field value autocomplete support

Wire distributed_tag_attributes_v2 for signoz_audit into the
metadata store. Add getAuditFieldValues() and route SignalLogs +
SourceAudit to it in GetFieldValues().

* test(audit): add statement builder tests

Cover all three request types (list, time series, scalar) with
audit-specific query patterns: materialized column filters, AND/OR
conditions, limit CTEs, and group-by expressions.

* refactor(audit): inline field key map into test file

Remove test_data.go and inline the audit field key map directly
into statement_builder_test.go with a compact helper function.

* style(audit): move column map to const.go, use sqlbuilder.As in metadata

Move logsV2Columns from field_mapper.go to const.go to colocate all
column definitions. Switch getAuditKeys() to use sb.As() instead of
raw string formatting. Fix FieldContext alignment.

* fix(audit): align table names with schema migration

Migration uses logs/distributed_logs (not logs_v2/distributed_logs_v2).
Rename LogsV2TableName to LogsTableName and LogsV2LocalTableName to
LogsLocalTableName to match the actual signoz_audit DDL.

* feat(audit): add integration test fixture for audit logs

AuditLog fixture inserts into all 5 signoz_audit tables matching
the schema migration DDL: distributed_logs (no resources_string,
has event_name), distributed_logs_resource, distributed_tag_attributes_v2,
distributed_logs_attribute_keys, distributed_logs_resource_keys.

* fix(audit): rename tag_attributes_v2 to tag_attributes

Migration uses tag_attributes/distributed_tag_attributes (no _v2
suffix). Rename constants and update all references including the
integration test fixture.

* feat(audit): wire audit statement builder into querier

Add auditStmtBuilder to querier struct and route LogAggregation
queries with source=audit to it in all three dispatch locations
(main query, live tail, shiftedQuery). Create and wire the full
audit query stack in signozquerier provider.

* test(audit): add integration tests for audit log querying

Cover the documented query patterns: list all events, filter by
principal ID, filter by outcome, filter by resource name+ID,
filter by principal type, scalar count for alerting, and
isolation test ensuring audit data doesn't leak into regular logs.

* fix(audit): revert sb.As in getAuditKeys, fix fixture column_names

Revert getAuditKeys to use raw SQL strings instead of sb.As() which
incorrectly treated string literals as column references. Add explicit
column_names to all ClickHouse insert calls in the audit fixture.

* fix(audit): remove debug assertion from integration test

* feat(audit): internalize resource filter in audit statement builder

Build the resource filter internally pointing at
signoz_audit.distributed_logs_resource. Add LogsResourceTableName
constant. Remove resourceFilterStmtBuilder from constructor params.
Update test expectations to use the audit resource table.

* fix(audit): rename resource.name to resource.kind, move to resource attributes

Align with schema change from SigNoz/signoz#10826:
- signoz.audit.resource.name renamed to signoz.audit.resource.kind
- resource.kind and resource.id moved from event attributes to OTel
  Resource attributes (resource JSON column)
- Materialized columns reduced from 7 to 5 (resource.kind and
  resource.id no longer materialized)

* refactor(audit): use pytest.mark.parametrize for filter integration tests

Consolidate filter test functions into a single parametrized test.
6/8 tests passing; resource kind+ID filter and scalar count need
further investigation (resource filter JSON key extraction with
dotted keys, scalar response format).

* fix(audit): add source to resource filter for correct metadata routing

Add source param to telemetryresourcefilter.New so the resource
filter's key selectors include Source when calling GetKeysMulti.
Without this, audit resource keys route to signoz_logs metadata
tables instead of signoz_audit. Fix scalar test to use table
response format (columns+data, not rows).

* refactor(audit): reuse querier fixtures in integration tests

Add source param to BuilderQuery and build_scalar_query in the
querier fixture. Replace custom _build_audit_query and
_build_audit_ts_query helpers with BuilderQuery and
build_scalar_query from the shared fixtures.

* refactor(audit): remove wrapper helpers, inline make_query_request calls

Remove _query_audit_raw and _query_audit_scalar helpers. Use
make_query_request, BuilderQuery, and build_scalar_query directly.
Compute time window at test execution time via _time_window() to
avoid stale module-level timestamps.

* refactor(audit): inline _time_window into test functions

* style(audit): use snake_case for pytest parametrize IDs

* refactor(audit): inline DEFAULT_ORDER using build_order_by

Use build_order_by from querier fixtures instead of OrderBy/
TelemetryFieldKey dataclasses. Allow BuilderQuery.order to accept
plain dicts alongside OrderBy objects.

* refactor(audit): inline all data setup, use distinct scenarios per test

Remove _insert_standard_audit_events helper. Each test now owns its
data: list_all uses alert-rule/saved-view/user resource types,
scalar_count uses multiple failures from different principals (count=2),
leak test uses a single organization event. Parametrized filter tests
keep the original 5-event dataset.

* fix(audit): remove silent empty-string guards in metadata store

Remove guards that silently returned nil/empty when audit DB params
were empty. All call sites now pass real constants, so misconfiguration
should fail loudly rather than produce silent empty results.

* style(audit): remove module docstring from integration test

* style: formatting fix in tables file

* style: formatting fix in tables file

* fix: add auditStmtBuilder nil param to querier_test.go

* fix: fix fmt
2026-04-09 08:12:32 +00:00
Nikhil Soni
6d1d028d4c refactor: setup types and interface for waterfall v3 (#10794)
* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* chore: rename resources field to follow otel

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-04-09 05:21:33 +00:00
swapnil-signoz
92660b457d feat: adding types changes and openapi spec (#10866)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: adding types changes and openapi spec

* refactor: review changes

* feat: generating OpenAPI spec

* refactor: updating create account types

* refactor: removing email domain function
2026-04-08 20:11:49 +00:00
nikhilmantri0902
e6900dabc8 chore: warnings added passing from queryResponse warning to host lists response struct 2026-04-09 00:09:38 +05:30
nikhilmantri0902
c1ba389b63 chore: add type for response and files rearrange 2026-04-08 23:35:53 +05:30
nikhilmantri0902
3a1f40234f Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-08 23:03:50 +05:30
Piyush Singariya
8bfadbc197 fix: has value fixes (#10864)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-08 13:22:54 +00:00
Nikhil Mantri
2e4891fa63 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-08 16:07:57 +05:30
Nikhil Mantri
04ebc0bec7 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-08 11:08:10 +05:30
nikhilmantri0902
6fa815c294 chore: modified getMetadata query 2026-04-07 18:55:57 +05:30
nikhilmantri0902
63ec518efb chore: added hostName logic 2026-04-07 17:36:15 +05:30
nikhilmantri0902
c4ca20dd90 chore: return errors from getMetadata and lint fix 2026-04-07 17:01:13 +05:30
nikhilmantri0902
e56cc4222b chore: return errors from getMetadata and lint fix 2026-04-07 16:57:35 +05:30
nikhilmantri0902
07d2944d7c chore: yarn generate api 2026-04-07 16:44:06 +05:30
nikhilmantri0902
dea01ae36a chore: hostStatusNone added for clarity that this field can be left empty as well in payload 2026-04-07 16:32:25 +05:30
nikhilmantri0902
62ea5b54e2 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-07 14:09:48 +05:30
nikhilmantri0902
61baa1be7a chore: code improvements 2026-04-07 13:49:00 +05:30
nikhilmantri0902
2e049556e4 chore: unified composite key function 2026-04-07 11:15:03 +05:30
nikhilmantri0902
7458fb4855 Merge branch 'main' into infraM/v2_hosts_list_api 2026-04-06 17:18:01 +05:30
nikhilmantri0902
5f55f3938b chore: added temporalities of metrics 2026-04-06 17:17:15 +05:30
nikhilmantri0902
861c682ea5 chore: nil pointer dereference fix in req.Filter 2026-04-04 20:52:08 +05:30
nikhilmantri0902
19aada656c chore: updated spec 2026-04-04 16:44:15 +05:30
nikhilmantri0902
37fb0e9254 Merge branch 'infraM/base_dependencies' into infraM/v2_hosts_list_api 2026-04-03 17:49:00 +05:30
nikhilmantri0902
aecfa1a174 chore: added validation on order by 2026-04-02 20:13:30 +05:30
nikhilmantri0902
b869d23d94 chore: moved funcs 2026-04-02 20:02:22 +05:30
nikhilmantri0902
6ee3d44f76 chore: removed isSendingK8sAgentsMetricsCode 2026-04-02 19:58:30 +05:30
nikhilmantri0902
462e554107 chore: yarn generate api 2026-04-02 14:49:15 +05:30
nikhilmantri0902
66afa73e6f chore: return status as a string 2026-04-02 14:39:02 +05:30
nikhilmantri0902
54c604bcf4 chore: added some unit tests 2026-04-02 14:20:27 +05:30
nikhilmantri0902
c1be02ba54 chore: added validate function 2026-04-02 14:14:34 +05:30
nikhilmantri0902
d3c7ba8f45 chore: disk usage 2026-04-02 14:01:18 +05:30
nikhilmantri0902
039c4a0496 fix: bug fix 2026-04-02 11:32:49 +05:30
nikhilmantri0902
51a94b6bbc chore: added logic for hosts v3 api 2026-04-02 02:52:28 +05:30
nikhilmantri0902
bbfbb94f52 chore: merged main 2026-04-01 00:45:40 +05:30
nikhilmantri0902
d1eb9ef16f chore: endpoint detail update 2026-03-31 16:16:31 +05:30
nikhilmantri0902
3db00f8bc3 chore: baseline setup 2026-03-31 15:27:18 +05:30
129 changed files with 9415 additions and 3799 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
@@ -93,6 +94,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return signoz.NewAuditorProviderFactories()
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},

View File

@@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
@@ -24,6 +25,7 @@ import (
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
@@ -133,6 +135,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
func(licensing licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
factories := signoz.NewAuditorProviderFactories()
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
panic(err)
}
return factories
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)

View File

@@ -364,3 +364,34 @@ serviceaccount:
analytics:
# toggle service account analytics
enabled: true
##################### Auditor #####################
auditor:
# Specifies the auditor provider to use.
# noop: discards all audit events (community default).
# otlphttp: exports audit events via OTLP HTTP (enterprise).
provider: noop
# The async channel capacity for audit events. Events are dropped when full (fail-open).
buffer_size: 1000
# The maximum number of events per export batch.
batch_size: 100
# The maximum time between export flushes.
flush_interval: 1s
otlphttp:
# The target scheme://host:port/path of the OTLP HTTP endpoint.
endpoint: http://localhost:4318/v1/logs
# Whether to use HTTP instead of HTTPS.
insecure: false
# The maximum duration for an export attempt.
timeout: 10s
# Additional HTTP headers sent with every export request.
headers: {}
retry:
# Whether to retry on transient failures.
enabled: true
# The initial wait time before the first retry.
initial_interval: 5s
# The upper bound on backoff interval.
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s

View File

@@ -403,27 +403,65 @@ components:
required:
- regions
type: object
CloudintegrationtypesAWSCollectionStrategy:
CloudintegrationtypesAWSCloudWatchLogsSubscription:
properties:
aws_logs:
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsStrategy'
aws_metrics:
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsStrategy'
s3_buckets:
additionalProperties:
items:
type: string
type: array
type: object
filterPattern:
type: string
logGroupNamePrefix:
type: string
required:
- logGroupNamePrefix
- filterPattern
type: object
CloudintegrationtypesAWSCloudWatchMetricStreamFilter:
properties:
metricNames:
items:
type: string
type: array
namespace:
type: string
required:
- namespace
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
connectionURL:
connectionUrl:
type: string
required:
- connectionURL
- connectionUrl
type: object
CloudintegrationtypesAWSConnectionArtifactRequest:
CloudintegrationtypesAWSIntegrationConfig:
properties:
enabledRegions:
items:
type: string
type: array
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- enabledRegions
- telemetryCollectionStrategy
type: object
CloudintegrationtypesAWSLogsCollectionStrategy:
properties:
subscriptions:
items:
$ref: '#/components/schemas/CloudintegrationtypesAWSCloudWatchLogsSubscription'
type: array
required:
- subscriptions
type: object
CloudintegrationtypesAWSMetricsCollectionStrategy:
properties:
streamFilters:
items:
$ref: '#/components/schemas/CloudintegrationtypesAWSCloudWatchMetricStreamFilter'
type: array
required:
- streamFilters
type: object
CloudintegrationtypesAWSPostableAccountConfig:
properties:
deploymentRegion:
type: string
@@ -435,46 +473,6 @@ components:
- deploymentRegion
- regions
type: object
CloudintegrationtypesAWSIntegrationConfig:
properties:
enabledRegions:
items:
type: string
type: array
telemetry:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- enabledRegions
- telemetry
type: object
CloudintegrationtypesAWSLogsStrategy:
properties:
cloudwatch_logs_subscriptions:
items:
properties:
filter_pattern:
type: string
log_group_name_prefix:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesAWSMetricsStrategy:
properties:
cloudwatch_metric_stream_filters:
items:
properties:
MetricNames:
items:
type: string
type: array
Namespace:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesAWSServiceConfig:
properties:
logs:
@@ -486,7 +484,7 @@ components:
properties:
enabled:
type: boolean
s3_buckets:
s3Buckets:
additionalProperties:
items:
type: string
@@ -498,6 +496,19 @@ components:
enabled:
type: boolean
type: object
CloudintegrationtypesAWSTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsCollectionStrategy'
s3Buckets:
additionalProperties:
items:
type: string
type: array
type: object
type: object
CloudintegrationtypesAccount:
properties:
agentReport:
@@ -561,6 +572,26 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesCloudIntegrationService:
nullable: true
properties:
cloudIntegrationId:
type: string
config:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
createdAt:
format: date-time
type: string
id:
type: string
type:
$ref: '#/components/schemas/CloudintegrationtypesServiceID'
updatedAt:
format: date-time
type: string
required:
- id
type: object
CloudintegrationtypesCollectedLogAttribute:
properties:
name:
@@ -581,13 +612,6 @@ components:
unit:
type: string
type: object
CloudintegrationtypesCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesConnectionArtifact:
properties:
aws:
@@ -595,12 +619,21 @@ components:
required:
- aws
type: object
CloudintegrationtypesConnectionArtifactRequest:
CloudintegrationtypesCredentials:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifactRequest'
ingestionKey:
type: string
ingestionUrl:
type: string
sigNozApiKey:
type: string
sigNozApiUrl:
type: string
required:
- aws
- sigNozApiUrl
- sigNozApiKey
- ingestionUrl
- ingestionKey
type: object
CloudintegrationtypesDashboard:
properties:
@@ -626,7 +659,7 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGettableAccountWithArtifact:
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifact'
@@ -645,7 +678,7 @@ components:
required:
- accounts
type: object
CloudintegrationtypesGettableAgentCheckInResponse:
CloudintegrationtypesGettableAgentCheckIn:
properties:
account_id:
type: string
@@ -694,12 +727,72 @@ components:
type: string
type: array
telemetry:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesOldAWSCollectionStrategy'
required:
- enabled_regions
- telemetry
type: object
CloudintegrationtypesPostableAgentCheckInRequest:
CloudintegrationtypesOldAWSCollectionStrategy:
properties:
aws_logs:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSLogsStrategy'
aws_metrics:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSMetricsStrategy'
provider:
type: string
s3_buckets:
additionalProperties:
items:
type: string
type: array
type: object
type: object
CloudintegrationtypesOldAWSLogsStrategy:
properties:
cloudwatch_logs_subscriptions:
items:
properties:
filter_pattern:
type: string
log_group_name_prefix:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesOldAWSMetricsStrategy:
properties:
cloudwatch_metric_stream_filters:
items:
properties:
MetricNames:
items:
type: string
type: array
Namespace:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesPostableAccount:
properties:
config:
$ref: '#/components/schemas/CloudintegrationtypesPostableAccountConfig'
credentials:
$ref: '#/components/schemas/CloudintegrationtypesCredentials'
required:
- config
- credentials
type: object
CloudintegrationtypesPostableAccountConfig:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
required:
- aws
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
account_id:
type: string
@@ -727,6 +820,8 @@ components:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
icon:
@@ -735,12 +830,10 @@ components:
type: string
overview:
type: string
serviceConfig:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
supported_signals:
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
title:
type: string
required:
@@ -749,9 +842,10 @@ components:
- icon
- overview
- assets
- supported_signals
- supportedSignals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceConfig:
properties:
@@ -760,6 +854,22 @@ components:
required:
- aws
type: object
CloudintegrationtypesServiceID:
enum:
- alb
- api-gateway
- dynamodb
- ec2
- ecs
- eks
- elasticache
- lambda
- msk
- rds
- s3sync
- sns
- sqs
type: string
CloudintegrationtypesServiceMetadata:
properties:
enabled:
@@ -783,6 +893,13 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -1098,6 +1215,93 @@ components:
enabled:
type: boolean
type: object
InframonitoringtypesHostRecord:
properties:
cpu:
format: double
type: number
diskUsage:
format: double
type: number
hostName:
type: string
load15:
format: double
type: number
memory:
format: double
type: number
meta:
additionalProperties: {}
nullable: true
type: object
status:
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
wait:
format: double
type: number
type: object
InframonitoringtypesHostStatus:
enum:
- active
- inactive
- ""
type: string
InframonitoringtypesHostsListRequest:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
filterByStatus:
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
type: object
InframonitoringtypesHostsListResponse:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
type: object
InframonitoringtypesRequiredMetricsCheck:
properties:
missingMetrics:
items:
type: string
nullable: true
type: array
type: object
InframonitoringtypesResponseType:
enum:
- list
- grouped_list
type: string
MetricsexplorertypesInspectMetricsRequest:
properties:
end:
@@ -3081,7 +3285,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
responses:
"200":
content:
@@ -3089,7 +3293,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
status:
type: string
required:
@@ -3190,22 +3394,22 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifactRequest'
$ref: '#/components/schemas/CloudintegrationtypesPostableAccount'
responses:
"200":
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithArtifact'
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithConnectionArtifact'
status:
type: string
required:
- status
- data
type: object
description: OK
description: Created
"401":
content:
application/json:
@@ -3394,6 +3598,61 @@ paths:
summary: Update account
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
operationId: UpdateService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: id
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update service
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in:
post:
deprecated: false
@@ -3409,7 +3668,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
responses:
"200":
content:
@@ -3417,7 +3676,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
status:
type: string
required:
@@ -3451,6 +3710,59 @@ paths:
summary: Agent check-in
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/credentials:
get:
deprecated: false
description: This endpoint retrieves the connection credentials required for
integration
operationId: GetConnectionCredentials
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesCredentials'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get connection credentials
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/services:
get:
deprecated: false
@@ -3458,6 +3770,11 @@ paths:
provider
operationId: ListServicesMetadata
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -3510,6 +3827,11 @@ paths:
description: This endpoint gets a service for the specified cloud provider
operationId: GetService
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -3561,55 +3883,6 @@ paths:
summary: Get service
tags:
- cloudintegration
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
operationId: UpdateService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update service
tags:
- cloudintegration
/api/v1/complete/google:
get:
deprecated: false
@@ -7180,6 +7453,72 @@ paths:
summary: Health check
tags:
- health
/api/v2/infra_monitoring/hosts:
post:
deprecated: false
description: 'Returns a paginated list of hosts with key infrastructure metrics:
CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute
load average. Each host includes its current status (active/inactive based
on metrics reported in the last 10 minutes) and metadata attributes (e.g.,
os.type). Supports filtering via a filter expression, filtering by host status,
custom groupBy to aggregate hosts by any attribute, ordering by any of the
five metrics, and pagination via offset/limit. The response type is ''list''
for the default host.name grouping or ''grouped_list'' for custom groupBy
keys. Also reports missing required metrics and whether the requested time
range falls before the data retention boundary.'
operationId: HostsList
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesHostsListRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesHostsListResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Hosts for Infra Monitoring
tags:
- infra-monitoring
/api/v2/livez:
get:
deprecated: false

View File

@@ -227,7 +227,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
apiHandler.RegisterRoutes(r, am)

View File

@@ -24,20 +24,24 @@ import type {
AgentCheckInDeprecated200,
AgentCheckInDeprecatedPathParameters,
AgentCheckInPathParameters,
CloudintegrationtypesConnectionArtifactRequestDTO,
CloudintegrationtypesPostableAgentCheckInRequestDTO,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesPostableAgentCheckInDTO,
CloudintegrationtypesUpdatableAccountDTO,
CloudintegrationtypesUpdatableServiceDTO,
CreateAccount200,
CreateAccount201,
CreateAccountPathParameters,
DisconnectAccountPathParameters,
GetAccount200,
GetAccountPathParameters,
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
ListServicesMetadataParams,
ListServicesMetadataPathParameters,
RenderErrorResponseDTO,
UpdateAccountPathParameters,
@@ -51,14 +55,14 @@ import type {
*/
export const agentCheckInDeprecated = (
{ cloudProvider }: AgentCheckInDeprecatedPathParameters,
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckInDeprecated200>({
url: `/api/v1/cloud-integrations/${cloudProvider}/agent-check-in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
data: cloudintegrationtypesPostableAgentCheckInDTO,
signal,
});
};
@@ -72,7 +76,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
@@ -81,7 +85,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
@@ -98,7 +102,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
Awaited<ReturnType<typeof agentCheckInDeprecated>>,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -112,7 +116,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
export type AgentCheckInDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckInDeprecated>>
>;
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
export type AgentCheckInDeprecatedMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -128,7 +132,7 @@ export const useAgentCheckInDeprecated = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
@@ -137,7 +141,7 @@ export const useAgentCheckInDeprecated = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
@@ -255,14 +259,14 @@ export const invalidateListAccounts = async (
*/
export const createAccount = (
{ cloudProvider }: CreateAccountPathParameters,
cloudintegrationtypesConnectionArtifactRequestDTO: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>,
cloudintegrationtypesPostableAccountDTO: BodyType<CloudintegrationtypesPostableAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAccount200>({
return GeneratedAPIInstance<CreateAccount201>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesConnectionArtifactRequestDTO,
data: cloudintegrationtypesPostableAccountDTO,
signal,
});
};
@@ -276,7 +280,7 @@ export const getCreateAccountMutationOptions = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
>;
@@ -285,7 +289,7 @@ export const getCreateAccountMutationOptions = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
> => {
@@ -302,7 +306,7 @@ export const getCreateAccountMutationOptions = <
Awaited<ReturnType<typeof createAccount>>,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -316,7 +320,7 @@ export const getCreateAccountMutationOptions = <
export type CreateAccountMutationResult = NonNullable<
Awaited<ReturnType<typeof createAccount>>
>;
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesPostableAccountDTO>;
export type CreateAccountMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -331,7 +335,7 @@ export const useCreateAccount = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
>;
@@ -340,7 +344,7 @@ export const useCreateAccount = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
> => {
@@ -628,330 +632,16 @@ export const useUpdateAccount = <
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
*/
export const agentCheckIn = (
{ cloudProvider }: AgentCheckInPathParameters,
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckIn200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
signal,
});
};
export const getAgentCheckInMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
> => {
const mutationKey = ['agentCheckIn'];
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 agentCheckIn>>,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return agentCheckIn(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type AgentCheckInMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckIn>>
>;
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Agent check-in
*/
export const useAgentCheckIn = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
},
TContext
> => {
const mutationOptions = getAgentCheckInMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint lists the services metadata for the specified cloud provider
* @summary List services metadata
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
signal,
});
};
export const getListServicesMetadataQueryKey = ({
cloudProvider,
}: ListServicesMetadataPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
};
export const getListServicesMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListServicesMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof listServicesMetadata>>
>;
export type ListServicesMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List services metadata
*/
export function useListServicesMetadata<
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List services metadata
*/
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
options,
);
return queryClient;
};
/**
* This endpoint gets a service for the specified cloud provider
* @summary Get service
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
signal,
});
};
export const getGetServiceQueryKey = ({
cloudProvider,
serviceId,
}: GetServicePathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
] as const;
};
export const getGetServiceQueryOptions = <
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && serviceId),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getService>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetServiceQueryResult = NonNullable<
Awaited<ReturnType<typeof getService>>
>;
export type GetServiceQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service
*/
export function useGetService<
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get service
*/
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, serviceId }: UpdateServicePathParameters,
{ cloudProvider, id, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
@@ -1039,3 +729,443 @@ export const useUpdateService = <
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
*/
export const agentCheckIn = (
{ cloudProvider }: AgentCheckInPathParameters,
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckIn200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInDTO,
signal,
});
};
export const getAgentCheckInMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
const mutationKey = ['agentCheckIn'];
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 agentCheckIn>>,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return agentCheckIn(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type AgentCheckInMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckIn>>
>;
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Agent check-in
*/
export const useAgentCheckIn = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof agentCheckIn>>,
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
const mutationOptions = getAgentCheckInMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint retrieves the connection credentials required for integration
* @summary Get connection credentials
*/
export const getConnectionCredentials = (
{ cloudProvider }: GetConnectionCredentialsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetConnectionCredentials200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/credentials`,
method: 'GET',
signal,
});
};
export const getGetConnectionCredentialsQueryKey = ({
cloudProvider,
}: GetConnectionCredentialsPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/credentials`] as const;
};
export const getGetConnectionCredentialsQueryOptions = <
TData = Awaited<ReturnType<typeof getConnectionCredentials>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: GetConnectionCredentialsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetConnectionCredentialsQueryKey({ cloudProvider });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getConnectionCredentials>>
> = ({ signal }) => getConnectionCredentials({ cloudProvider }, signal);
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetConnectionCredentialsQueryResult = NonNullable<
Awaited<ReturnType<typeof getConnectionCredentials>>
>;
export type GetConnectionCredentialsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get connection credentials
*/
export function useGetConnectionCredentials<
TData = Awaited<ReturnType<typeof getConnectionCredentials>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: GetConnectionCredentialsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetConnectionCredentialsQueryOptions(
{ cloudProvider },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get connection credentials
*/
export const invalidateGetConnectionCredentials = async (
queryClient: QueryClient,
{ cloudProvider }: GetConnectionCredentialsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetConnectionCredentialsQueryKey({ cloudProvider }) },
options,
);
return queryClient;
};
/**
* This endpoint lists the services metadata for the specified cloud provider
* @summary List services metadata
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
params,
signal,
});
};
export const getListServicesMetadataQueryKey = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services`,
...(params ? [params] : []),
] as const;
};
export const getListServicesMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListServicesMetadataQueryKey({ cloudProvider }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListServicesMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof listServicesMetadata>>
>;
export type ListServicesMetadataQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List services metadata
*/
export function useListServicesMetadata<
TData = Awaited<ReturnType<typeof listServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List services metadata
*/
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
options,
);
return queryClient;
};
/**
* This endpoint gets a service for the specified cloud provider
* @summary Get service
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
params,
signal,
});
};
export const getGetServiceQueryKey = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
...(params ? [params] : []),
] as const;
};
export const getGetServiceQueryOptions = <
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, params, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && serviceId),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getService>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetServiceQueryResult = NonNullable<
Awaited<ReturnType<typeof getService>>
>;
export type GetServiceQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service
*/
export function useGetService<
TData = Awaited<ReturnType<typeof getService>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get service
*/
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
options,
);
return queryClient;
};

View File

@@ -0,0 +1,104 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
HostsList200,
InframonitoringtypesHostsListRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.
* @summary List Hosts for Infra Monitoring
*/
export const hostsList = (
inframonitoringtypesHostsListRequestDTO: BodyType<InframonitoringtypesHostsListRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<HostsList200>({
url: `/api/v2/infra_monitoring/hosts`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesHostsListRequestDTO,
signal,
});
};
export const getHostsListMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof hostsList>>,
TError,
{ data: BodyType<InframonitoringtypesHostsListRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof hostsList>>,
TError,
{ data: BodyType<InframonitoringtypesHostsListRequestDTO> },
TContext
> => {
const mutationKey = ['hostsList'];
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 hostsList>>,
{ data: BodyType<InframonitoringtypesHostsListRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return hostsList(data);
};
return { mutationFn, ...mutationOptions };
};
export type HostsListMutationResult = NonNullable<
Awaited<ReturnType<typeof hostsList>>
>;
export type HostsListMutationBody = BodyType<InframonitoringtypesHostsListRequestDTO>;
export type HostsListMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Hosts for Infra Monitoring
*/
export const useHostsList = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof hostsList>>,
TError,
{ data: BodyType<InframonitoringtypesHostsListRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof hostsList>>,
TError,
{ data: BodyType<InframonitoringtypesHostsListRequestDTO> },
TContext
> => {
const mutationOptions = getHostsListMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -512,27 +512,58 @@ export interface CloudintegrationtypesAWSAccountConfigDTO {
regions: string[];
}
export type CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
export interface CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO {
/**
* @type object
* @type string
*/
s3_buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
filterPattern: string;
/**
* @type string
*/
logGroupNamePrefix: string;
}
export interface CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO {
/**
* @type array
*/
metricNames?: string[];
/**
* @type string
*/
namespace: string;
}
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
connectionURL: string;
connectionUrl: string;
}
export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
export interface CloudintegrationtypesAWSIntegrationConfigDTO {
/**
* @type array
*/
enabledRegions: string[];
telemetryCollectionStrategy: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
/**
* @type array
*/
subscriptions: CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO[];
}
export interface CloudintegrationtypesAWSMetricsCollectionStrategyDTO {
/**
* @type array
*/
streamFilters: CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO[];
}
export interface CloudintegrationtypesAWSPostableAccountConfigDTO {
/**
* @type string
*/
@@ -543,56 +574,6 @@ export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
regions: string[];
}
export interface CloudintegrationtypesAWSIntegrationConfigDTO {
/**
* @type array
*/
enabledRegions: string[];
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
}
export type CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
/**
* @type string
*/
filter_pattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
};
export interface CloudintegrationtypesAWSLogsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
| CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
export type CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
/**
* @type array
*/
MetricNames?: string[];
/**
* @type string
*/
Namespace?: string;
};
export interface CloudintegrationtypesAWSMetricsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
| CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
export interface CloudintegrationtypesAWSServiceConfigDTO {
logs?: CloudintegrationtypesAWSServiceLogsConfigDTO;
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
@@ -610,7 +591,7 @@ export interface CloudintegrationtypesAWSServiceLogsConfigDTO {
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
s3Buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
}
export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
@@ -620,6 +601,19 @@ export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
enabled?: boolean;
}
export type CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesAWSTelemetryCollectionStrategyDTO {
logs?: CloudintegrationtypesAWSLogsCollectionStrategyDTO;
metrics?: CloudintegrationtypesAWSMetricsCollectionStrategyDTO;
/**
* @type object
*/
s3Buckets?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets;
}
export interface CloudintegrationtypesAccountDTO {
agentReport: CloudintegrationtypesAgentReportDTO;
config: CloudintegrationtypesAccountConfigDTO;
@@ -693,6 +687,32 @@ export interface CloudintegrationtypesAssetsDTO {
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesCloudIntegrationServiceDTO = {
/**
* @type string
*/
cloudIntegrationId?: string;
config?: CloudintegrationtypesServiceConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
id: string;
type?: CloudintegrationtypesServiceIDDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
} | null;
export interface CloudintegrationtypesCollectedLogAttributeDTO {
/**
* @type string
@@ -727,16 +747,27 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesCollectionStrategyDTO {
aws: CloudintegrationtypesAWSCollectionStrategyDTO;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
}
export interface CloudintegrationtypesConnectionArtifactRequestDTO {
aws: CloudintegrationtypesAWSConnectionArtifactRequestDTO;
export interface CloudintegrationtypesCredentialsDTO {
/**
* @type string
*/
ingestionKey: string;
/**
* @type string
*/
ingestionUrl: string;
/**
* @type string
*/
sigNozApiKey: string;
/**
* @type string
*/
sigNozApiUrl: string;
}
export interface CloudintegrationtypesDashboardDTO {
@@ -768,7 +799,7 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGettableAccountWithArtifactDTO {
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
* @type string
@@ -783,7 +814,7 @@ export interface CloudintegrationtypesGettableAccountsDTO {
accounts: CloudintegrationtypesAccountDTO[];
}
export interface CloudintegrationtypesGettableAgentCheckInResponseDTO {
export interface CloudintegrationtypesGettableAgentCheckInDTO {
/**
* @type string
*/
@@ -831,17 +862,85 @@ export type CloudintegrationtypesIntegrationConfigDTO = {
* @type array
*/
enabled_regions: string[];
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
} | null;
export type CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesOldAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesOldAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesOldAWSMetricsStrategyDTO;
/**
* @type string
*/
provider?: string;
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets;
}
export type CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
/**
* @type string
*/
filter_pattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
};
export interface CloudintegrationtypesOldAWSLogsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
| CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
export type CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
/**
* @type array
*/
MetricNames?: string[];
/**
* @type string
*/
Namespace?: string;
};
export interface CloudintegrationtypesOldAWSMetricsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
| CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
export interface CloudintegrationtypesPostableAccountDTO {
config: CloudintegrationtypesPostableAccountConfigDTO;
credentials: CloudintegrationtypesCredentialsDTO;
}
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws: CloudintegrationtypesAWSPostableAccountConfigDTO;
}
/**
* @nullable
*/
export type CloudintegrationtypesPostableAgentCheckInRequestDTOData = {
export type CloudintegrationtypesPostableAgentCheckInDTOData = {
[key: string]: unknown;
} | null;
export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
export interface CloudintegrationtypesPostableAgentCheckInDTO {
/**
* @type string
*/
@@ -858,7 +957,7 @@ export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
* @type object
* @nullable true
*/
data: CloudintegrationtypesPostableAgentCheckInRequestDTOData;
data: CloudintegrationtypesPostableAgentCheckInDTOData;
/**
* @type string
*/
@@ -871,6 +970,7 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
* @type string
@@ -884,9 +984,8 @@ export interface CloudintegrationtypesServiceDTO {
* @type string
*/
overview: string;
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
/**
* @type string
*/
@@ -897,6 +996,21 @@ export interface CloudintegrationtypesServiceConfigDTO {
aws: CloudintegrationtypesAWSServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
alb = 'alb',
'api-gateway' = 'api-gateway',
dynamodb = 'dynamodb',
ec2 = 'ec2',
ecs = 'ecs',
eks = 'eks',
elasticache = 'elasticache',
lambda = 'lambda',
msk = 'msk',
rds = 'rds',
s3sync = 's3sync',
sns = 'sns',
sqs = 'sqs',
}
export interface CloudintegrationtypesServiceMetadataDTO {
/**
* @type boolean
@@ -927,6 +1041,10 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
config: CloudintegrationtypesAccountConfigDTO;
}
@@ -1330,6 +1448,116 @@ export interface GlobaltypesTokenizerConfigDTO {
enabled?: boolean;
}
/**
* @nullable
*/
export type InframonitoringtypesHostRecordDTOMeta = {
[key: string]: unknown;
} | null;
export interface InframonitoringtypesHostRecordDTO {
/**
* @type number
* @format double
*/
cpu?: number;
/**
* @type number
* @format double
*/
diskUsage?: number;
/**
* @type string
*/
hostName?: string;
/**
* @type number
* @format double
*/
load15?: number;
/**
* @type number
* @format double
*/
memory?: number;
/**
* @type object
* @nullable true
*/
meta?: InframonitoringtypesHostRecordDTOMeta;
status?: InframonitoringtypesHostStatusDTO;
/**
* @type number
* @format double
*/
wait?: number;
}
export enum InframonitoringtypesHostStatusDTO {
active = 'active',
inactive = 'inactive',
'' = '',
}
export interface InframonitoringtypesHostsListRequestDTO {
/**
* @type integer
* @format int64
*/
end?: number;
filter?: Querybuildertypesv5FilterDTO;
filterByStatus?: InframonitoringtypesHostStatusDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit?: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start?: number;
}
export interface InframonitoringtypesHostsListResponseDTO {
/**
* @type boolean
*/
endTimeBeforeRetention?: boolean;
/**
* @type array
* @nullable true
*/
records?: InframonitoringtypesHostRecordDTO[] | null;
requiredMetricsCheck?: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total?: number;
type?: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesRequiredMetricsCheckDTO {
/**
* @type array
* @nullable true
*/
missingMetrics?: string[] | null;
}
export enum InframonitoringtypesResponseTypeDTO {
list = 'list',
grouped_list = 'grouped_list',
}
export interface MetricsexplorertypesInspectMetricsRequestDTO {
/**
* @type integer
@@ -3450,7 +3678,7 @@ export type AgentCheckInDeprecatedPathParameters = {
cloudProvider: string;
};
export type AgentCheckInDeprecated200 = {
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
data: CloudintegrationtypesGettableAgentCheckInDTO;
/**
* @type string
*/
@@ -3471,8 +3699,8 @@ export type ListAccounts200 = {
export type CreateAccountPathParameters = {
cloudProvider: string;
};
export type CreateAccount200 = {
data: CloudintegrationtypesGettableAccountWithArtifactDTO;
export type CreateAccount201 = {
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
/**
* @type string
*/
@@ -3499,11 +3727,27 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
serviceId: string;
};
export type AgentCheckInPathParameters = {
cloudProvider: string;
};
export type AgentCheckIn200 = {
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
data: CloudintegrationtypesGettableAgentCheckInDTO;
/**
* @type string
*/
status: string;
};
export type GetConnectionCredentialsPathParameters = {
cloudProvider: string;
};
export type GetConnectionCredentials200 = {
data: CloudintegrationtypesCredentialsDTO;
/**
* @type string
*/
@@ -3513,6 +3757,14 @@ export type AgentCheckIn200 = {
export type ListServicesMetadataPathParameters = {
cloudProvider: string;
};
export type ListServicesMetadataParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type ListServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
@@ -3525,6 +3777,14 @@ export type GetServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type GetServiceParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type GetService200 = {
data: CloudintegrationtypesServiceDTO;
/**
@@ -3533,10 +3793,6 @@ export type GetService200 = {
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type CreateSessionByGoogleCallback303 = {
data: AuthtypesGettableTokenDTO;
/**
@@ -4192,6 +4448,14 @@ export type Healthz503 = {
status: string;
};
export type HostsList200 = {
data: InframonitoringtypesHostsListResponseDTO;
/**
* @type string
*/
status: string;
};
export type Livez200 = {
data: FactoryResponseDTO;
/**

View File

@@ -28,6 +28,17 @@
}
}
// In table/column view, keep action buttons visible at the viewport's right edge
.log-line-action-buttons.table-view-log-actions {
position: absolute;
top: 50%;
right: 8px;
left: auto;
transform: translateY(-50%);
margin: 0;
z-index: 5;
}
.lightMode {
.log-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);

View File

@@ -1,4 +1,6 @@
.log-state-indicator {
padding-left: 8px;
.line {
margin: 0 8px;
min-height: 24px;

View File

@@ -1,43 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { LogType } from './LogStateIndicator';
export function getRowBackgroundColor(
isDarkMode: boolean,
logType?: string,
): string {
if (isDarkMode) {
switch (logType) {
case LogType.INFO:
return `${Color.BG_ROBIN_500}40`;
case LogType.WARN:
return `${Color.BG_AMBER_500}40`;
case LogType.ERROR:
return `${Color.BG_CHERRY_500}40`;
case LogType.TRACE:
return `${Color.BG_FOREST_400}40`;
case LogType.DEBUG:
return `${Color.BG_AQUA_500}40`;
case LogType.FATAL:
return `${Color.BG_SAKURA_500}40`;
default:
return `${Color.BG_ROBIN_500}40`;
}
}
switch (logType) {
case LogType.INFO:
return Color.BG_ROBIN_100;
case LogType.WARN:
return Color.BG_AMBER_100;
case LogType.ERROR:
return Color.BG_CHERRY_100;
case LogType.TRACE:
return Color.BG_FOREST_200;
case LogType.DEBUG:
return Color.BG_AQUA_100;
case LogType.FATAL:
return Color.BG_SAKURA_100;
default:
return Color.BG_VANILLA_300;
}
}

View File

@@ -1,12 +0,0 @@
.logBodyCell {
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
color: var(--l2-foreground);
}

View File

@@ -1,117 +0,0 @@
import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import type { TableColumnDef } from '../../TanStackTableView/types';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import styles from './useLogsTableColumns.module.scss';
type UseLogsTableColumnsProps = {
fields: IField[];
linesPerRow: number;
fontSize: FontSize;
appendTo?: 'center' | 'end';
};
export function useLogsTableColumns({
fields,
fontSize,
appendTo = 'center',
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
id: 'state-indicator',
header: '',
pin: 'left',
enableMove: false,
enableResize: false,
enableRemove: false,
width: { fixed: 32 },
cell: ({ row }): ReactElement => (
<LogStateIndicator
fontSize={fontSize}
severityText={row.severity_text as string}
severityNumber={row.severity_number as number}
/>
),
};
const fieldColumns: TableColumnDef<ILog>[] = fields
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
.map(
(f): TableColumnDef<ILog> => ({
id: f.name,
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
}),
);
const timestampCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'timestamp',
)
? {
id: 'timestamp',
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
width: { min: 200 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
}
: null;
const bodyCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'body',
)
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => log.body,
width: { min: 640 },
cell: ({ value, isActive }): ReactElement => (
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
className={styles.logBodyCell}
/>
),
}
: null;
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -1,101 +0,0 @@
/* eslint-disable no-restricted-imports */
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from 'react';
/* eslint-enable no-restricted-imports */
import { createStore, StoreApi, useStore } from 'zustand';
const CLEAR_HOVER_DELAY_MS = 100;
type RowHoverState = {
hoveredRowId: string | null;
clearTimeoutId: ReturnType<typeof setTimeout> | null;
setHoveredRowId: (id: string | null) => void;
scheduleClearHover: (rowId: string) => void;
};
const createRowHoverStore = (): StoreApi<RowHoverState> =>
createStore<RowHoverState>((set, get) => ({
hoveredRowId: null,
clearTimeoutId: null,
setHoveredRowId: (id: string | null): void => {
const { clearTimeoutId } = get();
if (clearTimeoutId) {
clearTimeout(clearTimeoutId);
set({ clearTimeoutId: null });
}
set({ hoveredRowId: id });
},
scheduleClearHover: (rowId: string): void => {
const { clearTimeoutId } = get();
if (clearTimeoutId) {
clearTimeout(clearTimeoutId);
}
const timeoutId = setTimeout(() => {
const current = get().hoveredRowId;
if (current === rowId) {
set({ hoveredRowId: null, clearTimeoutId: null });
}
}, CLEAR_HOVER_DELAY_MS);
set({ clearTimeoutId: timeoutId });
},
}));
type RowHoverStore = StoreApi<RowHoverState>;
const RowHoverContext = createContext<RowHoverStore | null>(null);
export function RowHoverProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const storeRef = useRef<RowHoverStore | null>(null);
if (!storeRef.current) {
storeRef.current = createRowHoverStore();
}
return (
<RowHoverContext.Provider value={storeRef.current}>
{children}
</RowHoverContext.Provider>
);
}
const defaultStore = createRowHoverStore();
export const useIsRowHovered = (rowId: string): boolean => {
const store = useContext(RowHoverContext);
// Selector returns true only if this specific row is hovered
const isHovered = useStore(
store ?? defaultStore,
(s) => s.hoveredRowId === rowId,
);
return store ? isHovered : false;
};
export const useSetRowHovered = (rowId: string): (() => void) => {
const store = useContext(RowHoverContext);
return useCallback(() => {
if (store) {
const current = store.getState().hoveredRowId;
if (current !== rowId) {
store.getState().setHoveredRowId(rowId);
}
}
}, [store, rowId]);
};
export const useClearRowHovered = (rowId: string): (() => void) => {
const store = useContext(RowHoverContext);
return useCallback(() => {
if (store) {
store.getState().scheduleClearHover(rowId);
}
}, [store, rowId]);
};
export default RowHoverContext;

View File

@@ -1,111 +0,0 @@
import { ComponentProps, memo } from 'react';
import { TableComponents } from 'react-virtuoso';
import cx from 'classnames';
import { useClearRowHovered, useSetRowHovered } from './RowHoverContext';
import { FlatItem, TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type VirtuosoTableRowProps<TData> = ComponentProps<
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
>
>;
function TanStackCustomTableRow<TData>({
children,
item,
context,
...props
}: VirtuosoTableRowProps<TData>): JSX.Element {
const rowId = item.row.id;
const rowData = item.row.original;
// Stable callbacks for hover state management
const setHovered = useSetRowHovered(rowId);
const clearHovered = useClearRowHovered(rowId);
if (item.kind === 'expansion') {
return (
<tr {...props} className={tableStyles.tableRowExpansion}>
{children}
</tr>
);
}
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
extraClass,
);
return (
<tr
{...props}
className={rowClassName}
style={rowStyle}
onMouseEnter={setHovered}
onMouseLeave={clearHovered}
>
{children}
</tr>
);
}
// Custom comparison - only re-render when row identity or computed values change
function areTableRowPropsEqual<TData>(
prev: Readonly<VirtuosoTableRowProps<TData>>,
next: Readonly<VirtuosoTableRowProps<TData>>,
): boolean {
// Different row = must re-render
if (prev.item.row.id !== next.item.row.id) {
return false;
}
// Different kind (row vs expansion) = must re-render
if (prev.item.kind !== next.item.kind) {
return false;
}
// Same row, same kind - check if computed values would differ
// We compare the context callbacks and row data to determine this
const prevData = prev.item.row.original;
const nextData = next.item.row.original;
// Row data reference changed = potential re-render needed
if (prevData !== nextData) {
return false;
}
// Context callbacks changed = computed values may differ
if (prev.context !== next.context) {
// If context changed, check if the actual computed values differ
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
const nextActive = next.context?.isRowActive?.(nextData) ?? false;
if (prevActive !== nextActive) {
return false;
}
const prevClass = prev.context?.getRowClassName?.(prevData) ?? '';
const nextClass = next.context?.getRowClassName?.(nextData) ?? '';
if (prevClass !== nextClass) {
return false;
}
const prevStyle = prev.context?.getRowStyle?.(prevData);
const nextStyle = next.context?.getRowStyle?.(nextData);
if (prevStyle !== nextStyle) {
return false;
}
}
return true;
}
export default memo(
TanStackCustomTableRow,
areTableRowPropsEqual,
) as typeof TanStackCustomTableRow;

View File

@@ -1,105 +0,0 @@
import { memo, useCallback } from 'react';
import { Row as TanStackRowModel } from '@tanstack/react-table';
import { useIsRowHovered } from './RowHoverContext';
import { TanStackRowCell } from './TanStackRowCell';
import { TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type TanStackRowCellsProps<TData> = {
row: TanStackRowModel<TData>;
context: TableRowContext<TData> | undefined;
itemKind: 'row' | 'expansion';
hasSingleColumn: boolean;
};
function TanStackRowCellsInner<TData>({
row,
context,
itemKind,
hasSingleColumn,
}: TanStackRowCellsProps<TData>): JSX.Element {
// Only re-render this row when ITS hover state changes
const hasHovered = useIsRowHovered(row.id);
const rowData = row.original;
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
// Stable references via destructuring
const onRowClick = context?.onRowClick;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const handleClick = useCallback(() => {
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData);
}
}, [isRowActive, onRowDeactivate, onRowClick, rowData]);
if (itemKind === 'expansion') {
return (
<td
colSpan={context?.colCount ?? 1}
className={tableStyles.tableCellExpansion}
>
{context?.renderExpandedRow?.(rowData)}
</td>
);
}
return (
<>
{visibleCells.map((cell, index) => {
const isLastCell = index === lastCellIndex;
return (
<TanStackRowCell
key={cell.id}
cell={cell}
hasSingleColumn={hasSingleColumn}
isLastCell={isLastCell}
hasHovered={hasHovered}
rowData={rowData}
onClick={handleClick}
renderRowActions={context?.renderRowActions}
/>
);
})}
</>
);
}
// Custom comparison - only re-render when row data changes
function areRowCellsPropsEqual<TData>(
prev: Readonly<TanStackRowCellsProps<TData>>,
next: Readonly<TanStackRowCellsProps<TData>>,
): boolean {
return (
// Row identity
prev.row.id === next.row.id &&
// Row kind (row vs expansion)
prev.itemKind === next.itemKind &&
// Row data reference
prev.row.original === next.row.original &&
// Layout
prev.hasSingleColumn === next.hasSingleColumn &&
// Context callbacks for click handlers and row actions
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.renderRowActions === next.context?.renderRowActions &&
prev.context?.renderExpandedRow === next.context?.renderExpandedRow &&
prev.context?.colCount === next.context?.colCount
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TanStackRowCells = memo(
TanStackRowCellsInner,
areRowCellsPropsEqual as any,
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
export default TanStackRowCells;

View File

@@ -1,68 +0,0 @@
import type { ReactNode } from 'react';
import { memo } from 'react';
import type { Cell } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
export type TanStackRowCellProps<TData> = {
cell: Cell<TData, unknown>;
hasSingleColumn: boolean;
isLastCell: boolean;
hasHovered: boolean;
rowData: TData;
onClick: () => void;
renderRowActions?: (row: TData) => ReactNode;
};
function TanStackRowCellInner<TData>({
cell,
hasSingleColumn,
isLastCell,
hasHovered,
rowData,
onClick,
renderRowActions,
}: TanStackRowCellProps<TData>): JSX.Element {
return (
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLastCell && hasHovered && renderRowActions && (
<span className={tableStyles.tableViewRowActions}>
{renderRowActions(rowData)}
</span>
)}
</td>
);
}
function areTanStackRowCellPropsEqual<TData>(
prev: Readonly<TanStackRowCellProps<TData>>,
next: Readonly<TanStackRowCellProps<TData>>,
): boolean {
return (
prev.cell.id === next.cell.id &&
prev.cell.column.id === next.cell.column.id &&
Object.is(prev.cell.getValue(), next.cell.getValue()) &&
prev.hasSingleColumn === next.hasSingleColumn &&
prev.isLastCell === next.isLastCell &&
prev.hasHovered === next.hasHovered &&
prev.onClick === next.onClick &&
prev.renderRowActions === next.renderRowActions &&
prev.rowData === next.rowData
);
}
const TanStackRowCellMemo = memo(
TanStackRowCellInner,
areTanStackRowCellPropsEqual,
);
TanStackRowCellMemo.displayName = 'TanStackRowCell';
export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner;

View File

@@ -1,105 +0,0 @@
.tanStackTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
min-width: 100%;
max-width: 100%;
& td,
& th {
overflow: hidden;
min-width: 0;
box-sizing: border-box;
vertical-align: middle;
}
}
.tableCellText {
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
width: auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--tanstack-plain-body-line-clamp, 1);
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
max-width: 100%;
word-break: break-all;
}
.tableViewRowActions {
position: absolute;
top: 50%;
right: 8px;
left: auto;
transform: translateY(-50%);
margin: 0;
z-index: 5;
}
.tableCell {
padding: 0.3rem;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
}
.tableRow {
cursor: pointer;
position: relative;
&:hover {
.tableCell {
background-color: var(--row-hover-bg) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(--row-active-bg) !important;
}
}
}
.tableHeaderCell {
padding: 0.3rem;
height: 36px;
text-align: left;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-left: 1px solid var(--l2-border);
box-shadow: inset 0 -1px 0 var(--l2-border);
color: var(--l1-foreground);
// TODO: Remove this once background color (l1) is matching the actual background color of the page
&[data-dark-mode='true'] {
background: #0b0c0d;
}
&[data-dark-mode='false'] {
background: #fdfdfd;
}
}
.tableRowExpansion {
display: table-row;
}
.tableCellExpansion {
padding: 0.5rem;
vertical-align: top;
}

View File

@@ -1,20 +0,0 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
export type TanStackTableTextProps = {
children?: ReactNode;
className?: string;
};
function TanStackTableText({
children,
className,
}: TanStackTableTextProps): JSX.Element {
return (
<span className={cx(tableStyles.tableCellText, className)}>{children}</span>
);
}
export default TanStackTableText;

View File

@@ -1,135 +0,0 @@
.tanstackTableViewWrapper {
display: flex;
flex-direction: column;
width: 100%;
position: relative;
min-height: 0;
}
.tanstackFixedCol {
width: 32px;
min-width: 32px;
max-width: 32px;
}
.tanstackFillerCol {
width: 100%;
min-width: 0;
}
.tanstackActionsCol {
width: 0;
min-width: 0;
max-width: 0;
}
.tanstackLoadMoreContainer {
width: 100%;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 12px;
flex-shrink: 0;
}
.tanstackTableVirtuoso {
width: 100%;
overflow-x: scroll;
}
.tanstackTableFootLoaderCell {
text-align: center;
padding: 8px 0;
}
.tanstackTableVirtuosoScroll {
width: 100%;
overflow-x: scroll;
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
&.cellTypographySmall {
--tanstack-plain-cell-font-size: 11px;
--tanstack-plain-cell-line-height: 16px;
:global(table tr td),
:global(table thead th) {
font-size: 11px;
line-height: 16px;
letter-spacing: -0.07px;
}
}
&.cellTypographyMedium {
--tanstack-plain-cell-font-size: 13px;
--tanstack-plain-cell-line-height: 20px;
:global(table tr td),
:global(table thead th) {
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&.cellTypographyLarge {
--tanstack-plain-cell-font-size: 14px;
--tanstack-plain-cell-line-height: 24px;
:global(table tr td),
:global(table thead th) {
font-size: 14px;
line-height: 24px;
letter-spacing: -0.07px;
}
}
}
.tanstackLoadingOverlay {
position: absolute;
left: 50%;
bottom: 2rem;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}

View File

@@ -1,52 +0,0 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef, TanStackTableProps } from './types';
import { getColumnMinWidthPx } from './utils';
import viewStyles from './TanStackTableView.module.scss';
export function VirtuosoTableColGroup<TData>({
columns,
columnSizingProp,
table,
}: {
columns: TableColumnDef<TData>[];
columnSizingProp: TanStackTableProps<TData>['columnSizing'];
table: Table<TData>;
}): JSX.Element {
return (
<colgroup>
{columns.map((column, colIndex) => {
const columnId = column.id;
const isFixedColumn = column.width?.fixed != null;
const minWidthPx = getColumnMinWidthPx(column);
const persistedWidth = columnSizingProp?.[columnId];
const computedWidth = table.getColumn(columnId)?.getSize();
const effectiveWidth = persistedWidth ?? computedWidth;
if (isFixedColumn) {
return <col key={columnId} className={viewStyles.tanstackFixedCol} />;
}
const isLastColumn = colIndex === columns.length - 1;
if (isLastColumn) {
return (
<col
key={columnId}
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
/>
);
}
const widthPx =
effectiveWidth != null ? Math.max(effectiveWidth, minWidthPx) : minWidthPx;
return (
<col
key={columnId}
style={{
width: `${widthPx}px`,
minWidth: `${minWidthPx}px`,
}}
/>
);
})}
</colgroup>
);
}

View File

@@ -1,102 +0,0 @@
jest.mock('../TanStackTable.module.scss', () => ({
__esModule: true,
default: {
tableRow: 'tableRow',
tableRowActive: 'tableRowActive',
tableRowExpansion: 'tableRowExpansion',
},
}));
import { render, screen } from '@testing-library/react';
import TanStackCustomTableRow from '../TanStackCustomTableRow';
import type { FlatItem, TableRowContext } from '../types';
const makeItem = (id: string): FlatItem<{ id: string }> => ({
kind: 'row',
row: { original: { id } } as never,
});
const virtuosoAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
describe('TanStackCustomTableRow', () => {
it('renders children', async () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={undefined}
>
<td>cell</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(await screen.findByText('cell')).toBeInTheDocument();
});
it('applies active class when isRowActive returns true', () => {
const ctx: TableRowContext<{ id: string }> = {
isRowActive: (row) => row.id === '1',
colCount: 1,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx as never}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
});
it('does not apply active class when isRowActive returns false', () => {
const ctx: TableRowContext<{ id: string }> = {
isRowActive: (row) => row.id === 'other',
colCount: 1,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx as never}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
});
it('renders expansion row without RowHoverContext when kind is expansion', () => {
const item: FlatItem<{ id: string }> = {
kind: 'expansion',
row: { original: { id: '1' } } as never,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow {...virtuosoAttrs} item={item} context={undefined}>
<td>expanded content</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
});
});

View File

@@ -1,144 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { TableColumnDef } from '../types';
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (): any => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
const col = (
id: string,
overrides?: Partial<TableColumnDef<unknown>>,
): TableColumnDef<unknown> => ({
id,
header: id,
cell: (): null => null,
...overrides,
});
const header = {
id: 'col',
column: {
getCanResize: () => true,
getIsResizing: () => false,
columnDef: { header: 'col' },
},
getResizeHandler: () => jest.fn(),
getContext: () => ({}),
} as never;
describe('TanStackHeaderRow', () => {
it('renders column title', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('timestamp', { header: 'timestamp' })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByTitle('Timestamp')).toBeInTheDocument();
});
it('shows grip icon when enableMove is not false and pin is not set', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('body')}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /drag body/i }),
).toBeInTheDocument();
});
it('does NOT show grip icon when pin is set', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('indicator', { pin: 'left' })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /drag/i }),
).not.toBeInTheDocument();
});
it('shows remove button when enableRemove and canRemoveColumn are true', async () => {
const user = userEvent.setup();
const onRemoveColumn = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('name', { enableRemove: true })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
canRemoveColumn
onRemoveColumn={onRemoveColumn}
/>
</tr>
</thead>
</table>,
);
await user.click(screen.getByRole('button', { name: /column actions/i }));
await user.click(await screen.findByText(/remove column/i));
expect(onRemoveColumn).toHaveBeenCalledWith('name');
});
it('does NOT show remove button when enableRemove is absent', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('name')}
header={header}
isDarkMode={false}
hasSingleColumn={false}
canRemoveColumn
onRemoveColumn={jest.fn()}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /column actions/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,162 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TanStackRowCells from '../TanStackRow';
import type { TableRowContext } from '../types';
const flexRenderMock = jest.fn((def: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, _ctx?: unknown): unknown => flexRenderMock(def),
}));
type Row = { id: string };
function buildMockRow(
cells: { id: string }[],
rowData: Row = { id: 'r1' },
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: rowData,
getVisibleCells: () =>
cells.map((c, i) => ({
id: `cell-${i}`,
column: {
id: c.id,
columnDef: { cell: (): string => `content-${c.id}` },
},
getContext: (): Record<string, unknown> => ({}),
getValue: (): string => `content-${c.id}`,
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => flexRenderMock.mockClear());
it('renders a cell per visible column', () => {
const row = buildMockRow([{ id: 'col-a' }, { id: 'col-b' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={undefined}
itemKind="row"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
});
it('calls onRowClick when a cell is clicked', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = { colCount: 1, onRowClick };
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' });
});
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not render renderRowActions before hover', () => {
const ctx: TableRowContext<Row> = {
colCount: 1,
renderRowActions: () => <button type="button">action</button>,
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
// Row actions are not rendered until hover (useIsRowHovered returns false by default)
expect(
screen.queryByRole('button', { name: 'action' }),
).not.toBeInTheDocument();
});
it('renders expansion cell with renderExpandedRow content', async () => {
const row = {
original: { id: 'r1' },
getVisibleCells: () => [],
} as never;
const ctx: TableRowContext<Row> = {
colCount: 3,
renderExpandedRow: (r) => <div>expanded-{r.id}</div>,
};
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="expansion"
hasSingleColumn={false}
/>
</tr>
</tbody>
</table>,
);
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
});
});

View File

@@ -1,68 +0,0 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import TanStackTable from '../index';
import type { TableColumnDef, TanStackTableProps } from '../types';
jest.mock('react-virtuoso', () => ({
TableVirtuoso: forwardRef<unknown, { fixedHeaderContent?: () => JSX.Element }>(
function MockVirtuoso({ fixedHeaderContent }, _ref) {
return <div data-testid="virtuoso">{fixedHeaderContent?.()}</div>;
},
),
}));
jest.mock('../useTableParams', () => ({
useTableParams: (): Record<string, unknown> => ({
page: 1,
limit: 50,
orderBy: null,
setPage: jest.fn(),
setLimit: jest.fn(),
setOrderBy: jest.fn(),
}),
}));
jest.mock('../../Spinner', () => ({
__esModule: true,
default: ({ tip }: { tip: string }): JSX.Element => (
<div data-testid="spinner">{tip}</div>
),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
type Row = { id: string };
const col = (): TableColumnDef<Row> => ({
id: 'id',
header: 'ID',
cell: ({ row }): string => row.id,
accessorKey: 'id',
});
const baseProps: TanStackTableProps<Row> = {
data: [{ id: '1' }],
columns: [col()],
};
describe('TanStackTable', () => {
it('renders virtuoso when not loading', () => {
render(<TanStackTable {...baseProps} />);
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
});
it('shows loading spinner overlay when isLoading is true', () => {
render(<TanStackTable {...baseProps} isLoading />);
expect(screen.getByTestId('spinner')).toBeInTheDocument();
});
it('passes loadingTip to spinner', () => {
render(
<TanStackTable {...baseProps} isLoading loadingTip="Fetching hosts" />,
);
expect(screen.getByTestId('spinner')).toHaveTextContent('Fetching hosts');
});
});

View File

@@ -1,194 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { TableColumnDef } from '../types';
import { useTableColumns } from '../useTableColumns';
const mockGet = jest.fn();
const mockSet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: (key: string): string | null => mockGet(key),
}));
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void => mockSet(key, value),
}));
type Row = { id: string; name: string };
const col = (id: string, pin?: 'left' | 'right'): TableColumnDef<Row> => ({
id,
header: id,
cell: ({ value }): string => String(value),
...(pin ? { pin } : {}),
});
describe('useTableColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue(null);
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('returns definitions in original order when no persisted state', () => {
const defs = [col('timestamp'), col('body'), col('name')];
const { result } = renderHook(() => useTableColumns(defs));
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'timestamp',
'body',
'name',
]);
});
it('restores column order from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: ['name', 'body', 'timestamp'],
columnSizing: {},
removedColumnIds: [],
}),
);
const defs = [col('timestamp'), col('body'), col('name')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'name',
'body',
'timestamp',
]);
});
it('pinned columns always stay first regardless of persisted order', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: ['body', 'indicator'],
columnSizing: {},
removedColumnIds: [],
}),
);
const defs = [col('indicator', 'left'), col('body')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns[0].id).toBe('indicator');
});
it('excludes removed columns from tableProps.columns', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: [],
columnSizing: {},
removedColumnIds: ['name'],
}),
);
const defs = [col('body'), col('name')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['body']);
expect(result.current.activeColumnIds).toEqual(['body']);
});
it('activeColumnIds reflects only currently visible columns', () => {
const defs = [col('body'), col('timestamp'), col('name')];
const { result } = renderHook(() => useTableColumns(defs));
expect(result.current.activeColumnIds).toEqual(['body', 'timestamp', 'name']);
});
it('onRemoveColumn removes column and persists after debounce', () => {
const defs = [col('body'), col('name')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
act(() => {
result.current.tableProps.onRemoveColumn('body');
});
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual(['name']);
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
'test_table',
expect.stringContaining('"removedColumnIds":["body"]'),
);
});
it('onColumnOrderChange updates column order', () => {
const defs = [col('a'), col('b'), col('c')];
const { result } = renderHook(() => useTableColumns(defs));
act(() => {
result.current.tableProps.onColumnOrderChange([
col('c'),
col('b'),
col('a'),
]);
});
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'c',
'b',
'a',
]);
});
it('restores column sizing from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
columnOrder: [],
columnSizing: { body: 400 },
removedColumnIds: [],
}),
);
const defs = [col('body')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columnSizing).toEqual({ body: 400 });
});
it('debounces sizing writes to localStorage', () => {
const defs = [col('body')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
act(() => {
result.current.tableProps.onColumnSizingChange({ body: 500 });
});
expect(mockSet).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
'test_table',
expect.stringContaining('"body":500'),
);
});
it('falls back to definitions order when localStorage is corrupt', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGet.mockReturnValue('not-json');
const defs = [col('a'), col('b')];
const { result } = renderHook(() =>
useTableColumns(defs, { storageKey: 'test_table' }),
);
expect(result.current.tableProps.columns.map((c) => c.id)).toEqual([
'a',
'b',
]);
spy.mockRestore();
});
});

View File

@@ -1,105 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useTableParams } from '../useTableParams';
jest.mock('utils/nuqsParsers', () => ({
parseAsJsonNoValidate: (): any => ({
withDefault: (d: unknown): any => ({
withOptions: (): any => ({ _default: d }),
_default: d,
}),
}),
}));
jest.mock('nuqs', () => ({
parseAsInteger: {
withDefault: (d: number): any => ({
withOptions: (): any => ({ _default: d }),
_default: d,
}),
},
parseAsJson: (): any => ({
withDefault: (d: unknown): any => ({
withOptions: (): any => ({ _default: d }),
_default: d,
}),
}),
useQueryState: jest
.fn()
.mockImplementation((_key: string, parser: { _default: unknown }) => {
const [val, setVal] = (jest.requireActual(
'react',
) as typeof import('react')).useState(parser?._default);
return [val, setVal];
}),
}));
describe('useTableParams (local mode — enableQueryParams not set)', () => {
it('returns default page=1 and limit=50', () => {
const { result } = renderHook(() => useTableParams());
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(50);
expect(result.current.orderBy).toBeNull();
});
it('respects custom defaults', () => {
const { result } = renderHook(() =>
useTableParams(undefined, { page: 2, limit: 25 }),
);
expect(result.current.page).toBe(2);
expect(result.current.limit).toBe(25);
});
it('setPage updates page', () => {
const { result } = renderHook(() => useTableParams());
act(() => {
result.current.setPage(3);
});
expect(result.current.page).toBe(3);
});
it('setLimit updates limit', () => {
const { result } = renderHook(() => useTableParams());
act(() => {
result.current.setLimit(100);
});
expect(result.current.limit).toBe(100);
});
it('setOrderBy updates orderBy', () => {
const { result } = renderHook(() => useTableParams());
act(() => {
result.current.setOrderBy({ columnId: 'cpu', desc: true });
});
expect(result.current.orderBy).toEqual({ columnId: 'cpu', desc: true });
});
});
describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('uses nuqs state when enableQueryParams=true', () => {
const { result } = renderHook(() => useTableParams(true));
expect(result.current.page).toBe(1);
act(() => {
result.current.setPage(5);
});
expect(result.current.page).toBe(5);
});
it('uses prefixed keys when enableQueryParams is a string', () => {
const { result } = renderHook(() => useTableParams('pods', { page: 2 }));
expect(result.current.page).toBe(2);
act(() => {
result.current.setPage(4);
});
expect(result.current.page).toBe(4);
});
it('local state is ignored when enableQueryParams is set', () => {
const { result: local } = renderHook(() => useTableParams());
const { result: url } = renderHook(() => useTableParams(true));
act(() => {
local.current.setPage(99);
});
expect(url.current.page).toBe(1);
});
});

View File

@@ -1,618 +0,0 @@
import type { ComponentProps, CSSProperties, Ref } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { TableComponents } from 'react-virtuoso';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import {
DndContext,
DragEndEvent,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { TooltipProvider } from '@signozhq/ui';
import type { Row } from '@tanstack/react-table';
import {
ColumnDef,
ColumnPinningState,
ExpandedState,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Pagination } from 'antd';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import Spinner from '../Spinner';
import { RowHoverProvider } from './RowHoverContext';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import TanStackRowCells from './TanStackRow';
import TanStackTableText from './TanStackTableText';
import {
FlatItem,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
} from './types';
import { useTableParams } from './useTableParams';
import { buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
import viewStyles from './TanStackTableView.module.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
const PAGINATION_STYLE: CSSProperties = { marginTop: 12, textAlign: 'right' };
const noopColumnSizing = (): void => {};
function TanStackTableInner<TData>(
{
data,
columns,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
onRemoveColumn,
isLoading = false,
loadingTip = 'Loading',
enableQueryParams,
pagination,
onEndReached,
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowDeactivate,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
tableScrollerProps,
plainTextCellLineClamp,
cellTypographySize,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
const { page, limit, setPage, setLimit } = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
});
const [expanded, setExpanded] = useState<ExpandedState>({});
const columnPinning = useMemo<ColumnPinningState>(
() => ({
left: columns.filter((c) => c.pin === 'left').map((c) => c.id),
right: columns.filter((c) => c.pin === 'right').map((c) => c.id),
}),
[columns],
);
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() => columns.map((colDef) => buildTanstackColumnDef(colDef, isRowActive)),
[columns, isRowActive],
);
const getRowId = useCallback((row: TData, index: number): string => {
const r = row as Record<string, unknown>;
if (r != null && typeof r.id !== 'undefined') {
return String(r.id);
}
return String(index);
}, []);
const tableGetRowCanExpand = useCallback(
(row: Row<TData>): boolean =>
getRowCanExpand ? getRowCanExpand(row.original) : true,
[getRowCanExpand],
);
const table = useReactTable({
data,
columns: tanstackColumns,
enableColumnResizing: true,
enableColumnPinning: true,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: onColumnSizingChange ?? noopColumnSizing,
onExpandedChange: setExpanded,
state: {
columnSizing: columnSizingProp ?? {},
columnPinning,
expanded,
},
});
const tableRows = table.getRowModel().rows;
const flatItems = useMemo<FlatItem<TData>[]>(() => {
const result: FlatItem<TData>[] = [];
for (const row of tableRows) {
result.push({ kind: 'row', row });
if (renderExpandedRow && row.getIsExpanded()) {
result.push({ kind: 'expansion', row });
}
}
return result;
}, [tableRows, renderExpandedRow]);
const flatIndexForActiveRow = useMemo(() => {
if (activeRowIndex == null || activeRowIndex < 0) {
return -1;
}
for (let i = 0; i < flatItems.length; i++) {
const item = flatItems[i];
if (item.kind === 'row' && item.row.index === activeRowIndex) {
return i;
}
}
return -1;
}, [activeRowIndex, flatItems]);
useEffect(() => {
if (flatIndexForActiveRow < 0) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: flatIndexForActiveRow,
align: 'center',
behavior: 'auto',
});
}, [flatIndexForActiveRow]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
const columnIds = useMemo(() => columns.map((c) => c.id), [columns]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id || !onColumnOrderChange) {
return;
}
const activeCol = columns.find((c) => c.id === String(active.id));
const overCol = columns.find((c) => c.id === String(over.id));
if (
!activeCol ||
!overCol ||
activeCol.pin != null ||
overCol.pin != null ||
activeCol.enableMove === false
) {
return;
}
const oldIndex = columns.findIndex((c) => c.id === String(active.id));
const newIndex = columns.findIndex((c) => c.id === String(over.id));
if (oldIndex === -1 || newIndex === -1) {
return;
}
onColumnOrderChange(arrayMove(columns, oldIndex, newIndex));
},
[columns, onColumnOrderChange],
);
const hasSingleColumn = useMemo(
() => columns.filter((c) => !c.pin && c.enableRemove !== false).length <= 1,
[columns],
);
const canRemoveColumn = !hasSingleColumn;
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns, columnPinning],
);
const columnsById = useMemo(
() => new Map(columns.map((c) => [c.id, c] as const)),
[columns],
);
const virtuosoContext = useMemo<TableRowContext<TData>>(
() => ({
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowDeactivate,
renderExpandedRow,
colCount: columns.length,
isDarkMode,
plainTextCellLineClamp,
}),
[
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowDeactivate,
renderExpandedRow,
columns.length,
isDarkMode,
plainTextCellLineClamp,
],
);
const itemContent = useCallback(
(_index: number, item: FlatItem<TData>): JSX.Element => (
<TanStackRowCells
row={item.row}
itemKind={item.kind}
context={virtuosoContext}
hasSingleColumn={hasSingleColumn}
/>
),
[virtuosoContext, hasSingleColumn],
);
const tableHeader = useCallback(() => {
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
<tr>
{flatHeaders.map((header) => {
const column = columnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={onRemoveColumn}
canRemoveColumn={canRemoveColumn}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
sensors,
handleDragEnd,
columnIds,
flatHeaders,
columnsById,
isDarkMode,
hasSingleColumn,
onRemoveColumn,
canRemoveColumn,
]);
const handleEndReached = useCallback(
(index: number): void => {
onEndReached?.(index);
},
[onEndReached],
);
useImperativeHandle(
forwardedRef,
(): TanStackTableHandle =>
new Proxy(
{
goToPage: (p: number): void => {
setPage(p);
virtuosoRef.current?.scrollToIndex({
index: 0,
align: 'start',
});
},
} as TanStackTableHandle,
{
get(target, prop): unknown {
if (prop in target) {
return Reflect.get(target, prop);
}
const v = (virtuosoRef.current as unknown) as Record<string, unknown>;
const value = v?.[prop as string];
if (typeof value === 'function') {
return (value as (...a: unknown[]) => unknown).bind(virtuosoRef.current);
}
return value;
},
},
),
[setPage],
);
const showPagination = Boolean(pagination && !onEndReached);
const { className: tableScrollerClassName, ...restTableScrollerProps } =
tableScrollerProps ?? {};
const cellTypographyClass = useMemo((): string | undefined => {
if (cellTypographySize === 'small') {
return viewStyles.cellTypographySmall;
}
if (cellTypographySize === 'medium') {
return viewStyles.cellTypographyMedium;
}
if (cellTypographySize === 'large') {
return viewStyles.cellTypographyLarge;
}
return undefined;
}, [cellTypographySize]);
const virtuosoClassName = useMemo(
() =>
cx(
viewStyles.tanstackTableVirtuosoScroll,
cellTypographyClass,
tableScrollerClassName,
),
[cellTypographyClass, tableScrollerClassName],
);
const virtuosoTableStyle = useMemo(
() =>
({
'--tanstack-plain-body-line-clamp': plainTextCellLineClamp,
} as CSSProperties),
[plainTextCellLineClamp],
);
type VirtuosoTableComponentProps = ComponentProps<
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
>;
const virtuosoComponents = useMemo(
() => ({
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
<table className={tableStyles.tanStackTable} style={style}>
<VirtuosoTableColGroup
columns={columns}
columnSizingProp={columnSizingProp}
table={table}
/>
{children}
</table>
),
TableRow: TanStackCustomTableRow,
}),
[columns, columnSizingProp, table],
);
return (
<div className={viewStyles.tanstackTableViewWrapper}>
<RowHoverProvider>
<TooltipProvider>
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
itemContent={itemContent}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
/>
{isLoading && (
<div className={viewStyles.tanstackLoadingOverlay}>
<Spinner height="35px" tip={loadingTip} />
</div>
)}
{showPagination && pagination && (
<Pagination
style={PAGINATION_STYLE}
current={page}
pageSize={limit}
total={pagination.total}
showSizeChanger
onChange={(p, ps): void => {
setPage(p);
if (ps != null && ps !== limit) {
setLimit(ps);
}
}}
/>
)}
</TooltipProvider>
</RowHoverProvider>
</div>
);
}
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
props: TanStackTableProps<TData> & {
ref?: React.Ref<TanStackTableHandle>;
},
) => JSX.Element;
const TanStackTableBase = memo(
TanStackTableForward,
) as typeof TanStackTableForward;
/**
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
* optional drag-to-reorder headers, expandable rows, and Ant Design pagination or infinite scroll.
*
* @example Minimal usage
* ```tsx
* import TanStackTable from 'components/TanStackTableView';
* import type { TableColumnDef } from 'components/TanStackTableView/types';
*
* type Row = { id: string; name: string };
*
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'name',
* header: 'Name',
* accessorKey: 'name',
* cell: ({ value }) => <TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>,
* },
* ];
*
* function Example(): JSX.Element {
* return <TanStackTable<Row> data={rows} columns={columns} />;
* }
* ```
*
* @example Column definitions — `accessorFn`, custom header, pinned column
* ```tsx
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'id',
* header: 'ID',
* accessorKey: 'id',
* pin: 'left',
* width: { min: 80, default: 120 },
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* {
* id: 'computed',
* header: () => <span>Computed</span>,
* accessorFn: (row) => row.first + row.last,
* enableMove: false,
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* ];
* ```
*
* @example Controlled column sizing and reorder (persist in parent state)
* ```tsx
* import type { ColumnSizingState } from '@tanstack/react-table';
*
* const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
*
* <TanStackTable
* data={data}
* columns={columns}
* columnSizing={columnSizing}
* onColumnSizingChange={setColumnSizing}
* onColumnOrderChange={setColumns}
* onRemoveColumn={(id) => setColumns((cols) => cols.filter((c) => c.id !== id))}
* />
* ```
*
* @example Pagination (Ant Design). Omit `onEndReached` so the footer pager is shown.
* ```tsx
* <TanStackTable
* data={pageRows}
* columns={columns}
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
* enableQueryParams
* />
* ```
*
* @example Infinite scroll — use `onEndReached` instead of `pagination` (pagination UI is hidden when `onEndReached` is set).
* ```tsx
* <TanStackTable
* data={accumulatedRows}
* columns={columns}
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
* />
* ```
*
* @example Loading overlay and typography for plain string/number cells
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* isLoading={isFetching}
* loadingTip="Loading logs…"
* cellTypographySize="small"
* plainTextCellLineClamp={2}
* />
* ```
*
* @example Row styling, selection, and actions
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row) => setSelectedId(row.id)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}
* />
* ```
*
* @example Expandable rows
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* renderExpandedRow={(row) => <pre>{JSON.stringify(row.raw, null, 2)}</pre>}
* getRowCanExpand={(row) => Boolean(row.raw)}
* />
* ```
*
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
* ```tsx
* import type { TanStackTableHandle } from 'components/TanStackTableView/types';
*
* const ref = useRef<TanStackTableHandle>(null);
*
* <TanStackTable ref={ref} data={data} columns={columns} pagination={{ total, defaultLimit: 20 }} />;
*
* ref.current?.goToPage(2);
* ref.current?.scrollToIndex({ index: 0, align: 'start' });
* ```
*
* @example Scroll container props (className, `data-testid`, etc.). `data` is reserved by Virtuoso and cannot be passed here.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-table' }}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,
});
export default TanStackTable;

View File

@@ -1,112 +0,0 @@
import {
CSSProperties,
Dispatch,
HTMLAttributes,
ReactNode,
SetStateAction,
} from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import type {
ColumnSizingState,
Row as TanStackRowType,
} from '@tanstack/react-table';
export type SortState = { columnId: string; desc: boolean };
/** Sets `--tanstack-plain-cell-*` on the scroll root via CSS module classes (no data attributes). */
export type CellTypographySize = 'small' | 'medium' | 'large';
export type TableCellContext<TData> = {
row: TData;
value: unknown;
isActive: boolean;
rowIndex: number;
isExpanded: boolean;
canExpand: boolean;
toggleExpanded: () => void;
};
export type TableColumnDef<TData> = {
id: string;
header: string | (() => ReactNode);
cell: (context: TableCellContext<TData>) => ReactNode;
accessorKey?: keyof TData & string;
accessorFn?: (row: TData) => unknown;
pin?: 'left' | 'right';
enableMove?: boolean;
enableResize?: boolean;
enableRemove?: boolean;
enableSort?: boolean;
width?: {
fixed?: number;
min?: number;
default?: number;
};
};
export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type TableRowContext<TData> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (row: TData) => ReactNode;
colCount: number;
isDarkMode?: boolean;
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
plainTextCellLineClamp?: number;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
};
export type TanStackTableProps<TData> = {
data: TData[];
columns: TableColumnDef<TData>[];
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
onRemoveColumn?: (id: string) => void;
isLoading?: boolean;
loadingTip?: string;
enableQueryParams?: boolean | string;
pagination?: PaginationProps;
onEndReached?: (index: number) => void;
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (row: TData) => ReactNode;
getRowCanExpand?: (row: TData) => boolean;
/**
* Primitive cell values use `--tanstack-plain-cell-*` from the scroll container when `cellTypographySize` is set.
*/
plainTextCellLineClamp?: number;
/** Optional CSS-module typography tier for the scroll root (`--tanstack-plain-cell-font-size` / line-height + header `th`). */
cellTypographySize?: CellTypographySize;
/** Spread onto the Virtuoso scroll container. `data` is omitted — reserved by Virtuoso. */
tableScrollerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'data'>;
};
export type TanStackTableHandle = TableVirtuosoHandle & {
goToPage: (page: number) => void;
};
export type TableColumnsState<TData> = {
columns: TableColumnDef<TData>[];
columnSizing: ColumnSizingState;
onColumnSizingChange: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange: (cols: TableColumnDef<TData>[]) => void;
onRemoveColumn: (id: string) => void;
};

View File

@@ -1,200 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ColumnSizingState } from '@tanstack/react-table';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { TableColumnDef, TableColumnsState } from './types';
const DEBOUNCE_MS = 250;
type PersistedState = {
columnOrder: string[];
columnSizing: ColumnSizingState;
removedColumnIds: string[];
};
const EMPTY: PersistedState = {
columnOrder: [],
columnSizing: {},
removedColumnIds: [],
};
function readStorage(storageKey: string): PersistedState {
const raw = getFromLocalstorage(storageKey);
if (!raw) {
return EMPTY;
}
try {
const parsed = JSON.parse(raw) as PersistedState;
return {
columnOrder: Array.isArray(parsed.columnOrder) ? parsed.columnOrder : [],
columnSizing:
parsed.columnSizing && typeof parsed.columnSizing === 'object'
? Object.fromEntries(
Object.entries(parsed.columnSizing).filter(
([, v]) => typeof v === 'number' && Number.isFinite(v) && v > 0,
),
)
: {},
removedColumnIds: Array.isArray(parsed.removedColumnIds)
? parsed.removedColumnIds
: [],
};
} catch (e) {
console.error('useTableColumns: failed to parse storage', e);
return EMPTY;
}
}
type UseTableColumnsOptions = { storageKey?: string };
type UseTableColumnsResult<TData> = {
tableProps: TableColumnsState<TData>;
activeColumnIds: string[];
};
export function useTableColumns<TData>(
definitions: TableColumnDef<TData>[],
options?: UseTableColumnsOptions,
): UseTableColumnsResult<TData> {
const { storageKey } = options ?? {};
const [persisted, setPersisted] = useState<PersistedState>(() =>
storageKey ? readStorage(storageKey) : EMPTY,
);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(
() => persisted.columnSizing,
);
const pendingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const persistedRef = useRef(persisted);
persistedRef.current = persisted;
const columnSizingRef = useRef(columnSizing);
columnSizingRef.current = columnSizing;
const scheduleWrite = useCallback(() => {
if (!storageKey) {
return;
}
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
}
pendingRef.current = setTimeout(() => {
setToLocalstorage(
storageKey,
JSON.stringify({
...persistedRef.current,
columnSizing: columnSizingRef.current,
}),
);
}, DEBOUNCE_MS);
}, [storageKey]);
useEffect(() => {
scheduleWrite();
return (): void => {
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
}
};
}, [columnSizing, scheduleWrite]);
const handleColumnSizingChange: Dispatch<
SetStateAction<ColumnSizingState>
> = useCallback((updater) => {
setColumnSizing((prev) =>
typeof updater === 'function' ? updater(prev) : updater,
);
}, []);
const handleColumnOrderChange = useCallback(
(updated: TableColumnDef<TData>[]) => {
const newOrder = updated.map((c) => c.id);
setPersisted((prev) => {
const next = { ...prev, columnOrder: newOrder };
if (storageKey) {
setToLocalstorage(
storageKey,
JSON.stringify({
...next,
columnSizing: columnSizingRef.current,
}),
);
}
return next;
});
},
[storageKey],
);
const handleRemoveColumn = useCallback(
(id: string) => {
setPersisted((prev) => {
if (prev.removedColumnIds.includes(id)) {
return prev;
}
const next = {
...prev,
removedColumnIds: [...prev.removedColumnIds, id],
};
if (storageKey) {
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
}
pendingRef.current = setTimeout(() => {
setToLocalstorage(
storageKey,
JSON.stringify({
...next,
columnSizing: columnSizingRef.current,
}),
);
}, DEBOUNCE_MS);
}
return next;
});
},
[storageKey],
);
const columns = useMemo<TableColumnDef<TData>[]>(() => {
const removedSet = new Set(persisted.removedColumnIds);
const active = definitions.filter((d) => !removedSet.has(d.id));
if (persisted.columnOrder.length === 0) {
return active;
}
const orderMap = new Map(persisted.columnOrder.map((id, i) => [id, i]));
const pinned = active.filter((c) => c.pin != null);
const rest = active.filter((c) => c.pin == null);
const sortedRest = [...rest].sort((a, b) => {
const ai = orderMap.get(a.id) ?? Infinity;
const bi = orderMap.get(b.id) ?? Infinity;
return ai - bi;
});
return [...pinned, ...sortedRest];
}, [definitions, persisted]);
const activeColumnIds = useMemo(() => columns.map((c) => c.id), [columns]);
return {
tableProps: {
columns,
columnSizing,
onColumnSizingChange: handleColumnSizingChange,
onColumnOrderChange: handleColumnOrderChange,
onRemoveColumn: handleRemoveColumn,
},
activeColumnIds,
};
}

View File

@@ -1,72 +0,0 @@
import { useState } from 'react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { SortState } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
type Defaults = { page?: number; limit?: number; orderBy?: SortState | null };
type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
};
export function useTableParams(
enableQueryParams?: boolean | string,
defaults?: Defaults,
): TableParamsResult {
const prefix = typeof enableQueryParams === 'string' ? enableQueryParams : '';
const sep = prefix ? '_' : '';
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
const [localPage, setLocalPage] = useState(pageDefault);
const [localLimit, setLocalLimit] = useState(limitDefault);
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
orderByDefault,
);
const [urlPage, setUrlPage] = useQueryState(
`${prefix}${sep}page`,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
`${prefix}${sep}limit`,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
`${prefix}${sep}order_by`,
parseAsJsonNoValidate<SortState | null>()
.withDefault(orderByDefault as never)
.withOptions(NUQS_OPTIONS),
);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
};
}
return {
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
};
}

View File

@@ -1,67 +0,0 @@
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { TableColumnDef } from './types';
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
column.id;
const REM_PX = 16;
const MIN_WIDTH_DEFAULT_REM = 12;
export const getColumnMinWidthPx = <TData>(
column: TableColumnDef<TData>,
): number => {
if (column.width?.fixed != null) {
return column.width.fixed;
}
if (column.width?.min != null) {
return column.width.min;
}
return MIN_WIDTH_DEFAULT_REM * REM_PX;
};
export function buildTanstackColumnDef<TData>(
colDef: TableColumnDef<TData>,
isRowActive?: (row: TData) => boolean,
): ColumnDef<TData> {
const isFixed = colDef.width?.fixed != null;
const fixedWidth = colDef.width?.fixed;
const minWidthPx = getColumnMinWidthPx(colDef);
return {
id: colDef.id,
header:
typeof colDef.header === 'string'
? colDef.header
: (): ReactNode =>
typeof colDef.header === 'function' ? colDef.header() : null,
accessorFn: (row: TData): unknown => {
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}
if (colDef.accessorKey) {
return (row as Record<string, unknown>)[colDef.accessorKey];
}
return undefined;
},
enableResizing: colDef.enableResize !== false && !isFixed,
enableSorting: colDef.enableSort === true,
minSize: fixedWidth ?? minWidthPx,
size: colDef.width?.default ?? fixedWidth,
maxSize: fixedWidth,
cell: ({ row, getValue }): ReactNode => {
const rowData = row.original;
return colDef.cell({
row: rowData,
value: getValue(),
isActive: isRowActive?.(rowData) ?? false,
rowIndex: row.index,
isExpanded: row.getIsExpanded(),
canExpand: row.getCanExpand(),
toggleExpanded: (): void => {
row.toggleExpanded();
},
});
},
};
}

View File

@@ -1,34 +1,21 @@
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/sonner';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import RawLogView from 'components/Logs/RawLogView';
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import TanStackTable from 'components/TanStackTableView';
import type { TanStackTableHandle } from 'components/TanStackTableView/types';
import { useTableColumns } from 'components/TanStackTableView/useTableColumns';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import { QueryParams } from 'constants/query';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -43,10 +30,7 @@ function LiveLogsList({
isLoading,
handleChangeSelectedView,
}: LiveLogsListProps): JSX.Element {
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const ref = useRef<VirtuosoHandle>(null);
const { isConnectionLoading } = useEventSource();
@@ -82,46 +66,9 @@ function LiveLogsList({
...options.selectColumns,
]);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
});
const { tableProps } = useTableColumns(logsColumns, {
storageKey: LOCALSTORAGE.LOGS_LIST_COLUMNS,
});
const handleRemoveColumn = useCallback(
(columnId: string): void => {
tableProps.onRemoveColumn(columnId);
config.addColumn?.onRemove?.(columnId);
},
[tableProps, config.addColumn],
);
const makeOnLogCopy = useCallback(
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[pathname, setCopy],
);
const handleScrollToLog = useScrollToLog({
logs: formattedLogs,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
virtuosoRef: ref,
});
const getItemContent = useCallback(
@@ -211,48 +158,29 @@ function LiveLogsList({
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<TanStackTable
ref={ref as React.Ref<TanStackTableHandle>}
{...tableProps}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
onRemoveColumn={handleRemoveColumn}
data={formattedLogs}
<TanStackTableView
ref={ref}
isLoading={false}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
'--row-hover-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
} as CSSProperties)
}
onRowClick={(log): void => {
handleSetActiveLog(log);
tableViewProps={{
logs: formattedLogs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
ref={ref as React.Ref<VirtuosoHandle>}
ref={ref}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={formattedLogs}
totalCount={formattedLogs.length}

View File

@@ -221,7 +221,7 @@ function ColumnView({
onColumnOrderChange(formattedColumns);
};
const handleRowClick = (row: Row<Record<string, string>>): void => {
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
const currentLog = logs.find(({ id }) => id === row.original.id);
setShowActiveLog(true);

View File

@@ -0,0 +1,8 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
const RowHoverContext = createContext(false);
export const useRowHover = (): boolean => useContext(RowHoverContext);
export default RowHoverContext;

View File

@@ -0,0 +1,84 @@
import { ComponentProps, memo, useCallback, useState } from 'react';
import { TableComponents } from 'react-virtuoso';
import {
getLogIndicatorType,
getLogIndicatorTypeForTable,
} from 'components/Logs/LogStateIndicator/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ILog } from 'types/api/logs/log';
import { TableRowStyled } from '../InfinityTableView/styles';
import RowHoverContext from '../RowHoverContext';
import { TanStackTableRowData } from './types';
export type TableRowContext = {
activeLog?: ILog | null;
activeContextLog?: ILog | null;
logsById: Map<string, ILog>;
};
type VirtuosoTableRowProps = ComponentProps<
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
>;
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
function TanStackCustomTableRow({
children,
item,
context,
...props
}: TanStackCustomTableRowProps): JSX.Element {
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
const isDarkMode = useIsDarkMode();
const [hasHovered, setHasHovered] = useState(false);
const rowId = String(item.currentLog.id ?? '');
const activeLog = context?.activeLog;
const activeContextLog = context?.activeContextLog;
const logsById = context?.logsById;
const rowLog = logsById?.get(rowId) || item.currentLog;
const logType = rowLog
? getLogIndicatorType(rowLog)
: getLogIndicatorTypeForTable(item.log);
const handleMouseEnter = useCallback(() => {
if (!hasHovered) {
setHasHovered(true);
}
}, [hasHovered]);
return (
<RowHoverContext.Provider value={hasHovered}>
<TableRowStyled
{...props}
$isDarkMode={isDarkMode}
$isActiveLog={
isHighlighted ||
rowId === String(activeLog?.id ?? '') ||
rowId === String(activeContextLog?.id ?? '')
}
$logType={logType}
onMouseEnter={handleMouseEnter}
>
{children}
</TableRowStyled>
</RowHoverContext.Provider>
);
}
export default memo(TanStackCustomTableRow, (prev, next) => {
const prevId = String(prev.item.currentLog.id ?? '');
const nextId = String(next.item.currentLog.id ?? '');
if (prevId !== nextId) {
return false;
}
const prevIsActive =
prevId === String(prev.context?.activeLog?.id ?? '') ||
prevId === String(prev.context?.activeContextLog?.id ?? '');
const nextIsActive =
nextId === String(next.context?.activeLog?.id ?? '') ||
nextId === String(next.context?.activeContextLog?.id ?? '');
return prevIsActive === nextIsActive;
});

View File

@@ -8,46 +8,51 @@ import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import cx from 'classnames';
import { GripVertical } from 'lucide-react';
import { TableColumnDef } from './types';
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { OrderedColumn, TanStackTableRowData } from './types';
import { getColumnId } from './utils';
import headerStyles from './TanStackHeaderRow.module.scss';
import tableStyles from './TanStackTable.module.scss';
import './styles/TanStackHeaderRow.styles.scss';
type TanStackHeaderRowProps<TData = unknown> = {
column: TableColumnDef<TData>;
header?: TanStackHeader<TData, unknown>;
type TanStackHeaderRowProps = {
column: OrderedColumn;
header?: TanStackHeader<TanStackTableRowData, unknown>;
isDarkMode: boolean;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnId: string) => void;
onRemoveColumn?: (columnKey: string) => void;
};
const GRIP_ICON_SIZE = 12;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow<TData>({
function TanStackHeaderRow({
column,
header,
isDarkMode,
fontSize,
hasSingleColumn,
canRemoveColumn = false,
onRemoveColumn,
}: TanStackHeaderRowProps<TData>): JSX.Element {
const columnId = column.id;
const isDragColumn = column.enableMove !== false && column.pin == null;
const isResizableColumn =
column.enableResize !== false && Boolean(header?.column.getCanResize());
}: TanStackHeaderRowProps): JSX.Element {
const columnId = getColumnId(column);
const isDragColumn =
column.key !== 'expand' && column.key !== 'state-indicator';
const isResizableColumn = Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn && onRemoveColumn && column.enableRemove,
canRemoveColumn &&
onRemoveColumn &&
column.key !== 'expand' &&
column.key !== 'state-indicator',
);
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.header === 'string' && column.header
? column.header
typeof column.title === 'string' && column.title
? column.title
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleResizeStart = (
@@ -78,57 +83,54 @@ function TanStackHeaderRow<TData>({
} as CSSProperties),
[isResizing, transform?.x, transform?.y, transition],
);
const headerCellClassName = cx(
headerStyles.tanstackHeaderCell,
isDragging && headerStyles.isDragging,
isResizing && headerStyles.isResizing,
);
const headerContentClassName = cx(
headerStyles.tanstackHeaderContent,
isResizableColumn && headerStyles.hasResizeControl,
isColumnRemovable && headerStyles.hasActionControl,
);
const thClassName = cx(
tableStyles.tableHeaderCell,
headerCellClassName,
column.id,
);
const headerCellClassName = [
'tanstack-header-cell',
isDragging ? 'is-dragging' : '',
isResizing ? 'is-resizing' : '',
]
.filter(Boolean)
.join(' ');
const headerContentClassName = [
'tanstack-header-content',
isResizableColumn ? 'has-resize-control' : '',
isColumnRemovable ? 'has-action-control' : '',
]
.filter(Boolean)
.join(' ');
return (
<th
<TableHeaderCellStyled
ref={setNodeRef}
className={thClassName}
$isLogIndicator={column.key === 'state-indicator'}
$isDarkMode={isDarkMode}
$isDragColumn={false}
className={headerCellClassName}
key={columnId}
fontSize={fontSize}
$hasSingleColumn={hasSingleColumn}
style={headerCellStyle}
data-dark-mode={isDarkMode}
data-single-column={hasSingleColumn || undefined}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className={headerStyles.tanstackGripSlot}>
<span className="tanstack-grip-slot">
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
(typeof column.header === 'string' && column.header) ||
header?.id ||
columnId,
column.title || header?.id || columnId,
)} column`}
className={headerStyles.tanstackGripActivator}
className="tanstack-grip-activator"
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
<span className="tanstack-header-title" title={headerTitleAttr}>
{header?.column?.columnDef
{header
? flexRender(header.column.columnDef.header, header.getContext())
: typeof column.header === 'function'
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
{isColumnRemovable && (
<Popover>
@@ -136,7 +138,7 @@ function TanStackHeaderRow<TData>({
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className={headerStyles.tanstackHeaderActionTrigger}
className="tanstack-header-action-trigger"
onMouseDown={(event): void => {
event.stopPropagation();
}}
@@ -147,20 +149,18 @@ function TanStackHeaderRow<TData>({
<PopoverContent
align="end"
sideOffset={6}
className={headerStyles.tanstackColumnActionsContent}
className="tanstack-column-actions-content"
>
<button
type="button"
className={headerStyles.tanstackRemoveColumnAction}
className="tanstack-remove-column-action"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(column.id);
onRemoveColumn?.(String(column.key));
}}
>
<CloseOutlined
className={headerStyles.tanstackRemoveColumnActionIcon}
/>
<CloseOutlined className="tanstack-remove-column-action-icon" />
Remove column
</button>
</PopoverContent>
@@ -170,7 +170,7 @@ function TanStackHeaderRow<TData>({
{isResizableColumn && (
<span
role="presentation"
className={headerStyles.cursorColResize}
className="cursor-col-resize"
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
@@ -183,10 +183,10 @@ function TanStackHeaderRow<TData>({
handleResizeStart(event);
}}
>
<span className={headerStyles.tanstackResizeHandleLine} />
<span className="tanstack-resize-handle-line" />
</span>
)}
</th>
</TableHeaderCellStyled>
);
}

View File

@@ -0,0 +1,95 @@
import {
MouseEvent as ReactMouseEvent,
MouseEventHandler,
useCallback,
} from 'react';
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { TableCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { useRowHover } from '../RowHoverContext';
import { TanStackTableRowData } from './types';
type TanStackRowCellsProps = {
row: TanStackRowModel<TanStackTableRowData>;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
isActiveLog?: boolean;
isDarkMode: boolean;
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
isLogsExplorerPage: boolean;
};
function TanStackRowCells({
row,
fontSize,
onSetActiveLog,
onClearActiveLog,
isActiveLog = false,
isDarkMode,
onLogCopy,
isLogsExplorerPage,
}: TanStackRowCellsProps): JSX.Element {
const { currentLog } = row.original;
const hasHovered = useRowHover();
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
},
[currentLog, onSetActiveLog],
);
const handleShowLogDetails = useCallback(() => {
if (!currentLog) {
return;
}
if (isActiveLog && onClearActiveLog) {
onClearActiveLog();
return;
}
onSetActiveLog?.(currentLog);
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
return (
<>
{visibleCells.map((cell, index) => {
const columnKey = cell.column.id;
const isLastCell = index === lastCellIndex;
return (
<TableCellStyled
$isDragColumn={false}
$isLogIndicator={columnKey === 'state-indicator'}
$hasSingleColumn={visibleCells.length <= 2}
$isDarkMode={isDarkMode}
key={cell.id}
fontSize={fontSize}
className={columnKey}
onClick={handleShowLogDetails}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLastCell && isLogsExplorerPage && hasHovered && (
<LogLinesActionButtons
handleShowContext={handleShowContext}
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
customClassName="table-view-log-actions"
/>
)}
</TableCellStyled>
);
})}
</>
);
}
export default TanStackRowCells;

View File

@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import TanStackCustomTableRow, {
TableRowContext,
} from '../TanStackCustomTableRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableRowStyled: 'tr',
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
getLogIndicatorType: (): string => 'info',
getLogIndicatorTypeForTable: (): string => 'info',
}));
const item: TanStackTableRowData = {
log: {},
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
rowIndex: 0,
};
const virtuosoTableRowAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
const defaultContext: TableRowContext = {
activeLog: null,
activeContextLog: null,
logsById: new Map(),
};
describe('TanStackCustomTableRow', () => {
it('renders children inside TableRowStyled', () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={defaultContext}
>
<td>cell</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('cell')).toBeInTheDocument();
});
it('marks row active when activeLog matches item id', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
activeLog: { id: 'row-1' } as never,
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
const row = container.querySelector('tr');
expect(row).toBeTruthy();
});
it('uses logsById entry when present for indicator type', () => {
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
logsById: new Map([['row-1', logFromMap]]),
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('x')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
import type { Header } from '@tanstack/react-table';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { OrderedColumn, TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableHeaderCellStyled: 'th',
}));
const mockUseSortable = jest.fn((_args?: unknown) => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: undefined,
isDragging: false,
}));
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
mockUseSortable(args),
}));
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => {
if (typeof def === 'string') {
return def;
}
if (typeof def === 'function') {
return (def as (c: unknown) => unknown)(ctx);
}
return def;
},
}));
const column = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
const mockHeader = (
id: string,
canResize = true,
): Header<TanStackTableRowData, unknown> =>
(({
id,
column: {
getCanResize: (): boolean => canResize,
getIsResizing: (): boolean => false,
columnDef: { header: id },
},
getContext: (): unknown => ({}),
getResizeHandler: (): (() => void) => jest.fn(),
flexRender: undefined,
} as unknown) as Header<TanStackTableRowData, unknown>);
describe('TanStackHeaderRow', () => {
beforeEach(() => {
mockUseSortable.mockClear();
});
it('renders column title when header is undefined', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('timestamp')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByText('Timestamp')).toBeInTheDocument();
});
it('enables useSortable for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
id: 'body',
disabled: false,
}),
);
});
it('disables sortable for expand column', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('expand')}
header={mockHeader('expand', false)}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
disabled: true,
}),
);
});
it('shows drag grip for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /Drag body column/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react';
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackRowCells from '../TanStackRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableCellStyled: 'td',
}));
jest.mock(
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
() => ({
__esModule: true,
default: ({
onLogCopy,
}: {
onLogCopy: (e: React.MouseEvent) => void;
}): JSX.Element => (
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
copy
</button>
),
}),
);
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
}));
function buildMockRow(
visibleCells: Array<{ columnId: string }>,
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: {
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
log: {},
rowIndex: 0,
},
getVisibleCells: () =>
visibleCells.map((cell, index) => ({
id: `cell-${index}`,
column: {
id: cell.columnId,
columnDef: {
cell: (): string => `content-${cell.columnId}`,
},
},
getContext: (): Record<string, unknown> => ({}),
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => {
flexRenderMock.mockClear();
});
it('renders a cell per visible column and calls flexRender', () => {
const row = buildMockRow([
{ columnId: 'state-indicator' },
{ columnId: 'body' },
]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
expect(flexRenderMock).toHaveBeenCalled();
});
it('applies state-indicator styling class on the indicator cell', () => {
const row = buildMockRow([{ columnId: 'state-indicator' }]);
const { container } = render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
});
it('renders row actions on logs explorer page after hover', () => {
const row = buildMockRow([{ columnId: 'body' }]);
render(
<RowHoverContext.Provider value>
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage
/>
</tr>
</tbody>
</table>
</RowHoverContext.Provider>,
);
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('click on a data cell calls onSetActiveLog with current log', () => {
const onSetActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onSetActiveLog={onSetActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onSetActiveLog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'log-1' }),
);
});
it('when row is active log, click on cell clears active log', () => {
const onSetActiveLog = jest.fn();
const onClearActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
isActiveLog
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onClearActiveLog).toHaveBeenCalled();
expect(onSetActiveLog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,104 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import type { InfinityTableProps } from '../../InfinityTableView/types';
import TanStackTableView from '../index';
jest.mock('react-virtuoso', () => ({
TableVirtuoso: forwardRef<
unknown,
{
fixedHeaderContent?: () => JSX.Element;
itemContent: (i: number) => JSX.Element;
}
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
return (
<div data-testid="virtuoso">
{fixedHeaderContent?.()}
{itemContent(0)}
</div>
);
}),
}));
jest.mock('components/Logs/TableView/useTableView', () => ({
useTableView: (): {
dataSource: Record<string, string>[];
columns: unknown[];
} => ({
dataSource: [{ id: '1' }],
columns: [
{ key: 'body', title: 'body', render: (): string => 'x' },
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
],
}),
}));
jest.mock('hooks/useDragColumns', () => ({
__esModule: true,
default: (): {
draggedColumns: unknown[];
onColumnOrderChange: () => void;
} => ({
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
}));
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
}));
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn() },
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: ({ tip }: { tip: string }): JSX.Element => (
<div data-testid="spinner">{tip}</div>
),
}));
const baseProps: InfinityTableProps = {
isLoading: false,
tableViewProps: {
logs: [{ id: '1' } as never],
fields: [],
linesPerRow: 3,
fontSize: FontSize.SMALL,
appendTo: 'end',
activeLogIndex: 0,
},
};
describe('TanStackTableView', () => {
it('shows spinner while loading', () => {
render(<TanStackTableView {...baseProps} isLoading />);
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
});
it('renders virtuoso when not loading', () => {
render(<TanStackTableView {...baseProps} />);
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import type { OrderedColumn } from '../types';
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
const mockGet = jest.fn();
const mockSet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: (key: string): string | null => mockGet(key),
}));
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void => {
mockSet(key, value);
},
}));
const col = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
describe('useColumnSizingPersistence', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue(null);
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('initializes with empty sizing when localStorage is empty', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({});
});
it('parses flat ColumnSizingState from localStorage', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
});
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
mockGet.mockReturnValue(
JSON.stringify({
version: 1,
columnIdsSignature: 'body|timestamp',
sizing: { body: 300 },
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 300 });
});
it('drops invalid numeric entries when reading from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
body: 200,
bad: NaN,
zero: 0,
neg: -1,
str: 'wide',
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
);
expect(result.current.columnSizing).toEqual({ body: 200 });
});
it('returns empty sizing when JSON is invalid', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGet.mockReturnValue('not-json');
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
expect(result.current.columnSizing).toEqual({});
spy.mockRestore();
});
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
const { result, rerender } = renderHook(
({ columns }: { columns: OrderedColumn[] }) =>
useColumnSizingPersistence(columns),
{
initialProps: {
columns: [
col('body'),
col('expand'),
col('state-indicator'),
] as OrderedColumn[],
},
},
);
expect(result.current.columnSizing).toEqual({ body: 400 });
act(() => {
rerender({
columns: [col('body'), col('expand'), col('state-indicator')],
});
});
expect(result.current.columnSizing).toEqual({ body: 400 });
});
it('updates setColumnSizing manually', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 500 });
});
expect(result.current.columnSizing).toEqual({ body: 500 });
});
it('debounces writes to localStorage', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 600 });
});
expect(mockSet).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
expect.stringContaining('"body":600'),
);
});
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
const { result } = renderHook(() => useColumnSizingPersistence([]));
expect(result.current.columnSizing).toEqual({});
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,222 @@
import { act, renderHook } from '@testing-library/react';
import type { OrderedColumn } from '../types';
import { useOrderedColumns } from '../useOrderedColumns';
const mockGetDraggedColumns = jest.fn();
jest.mock('hooks/useDragColumns/utils', () => ({
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
mockGetDraggedColumns(current, dragged) as T[],
}));
const col = (key: string, title?: string): OrderedColumn =>
({ key, title: title ?? key } as OrderedColumn);
describe('useOrderedColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
mockGetDraggedColumns.mockReturnValue([
col('body'),
col('timestamp'),
{ title: 'no-key' },
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.orderedColumns).toEqual([
col('body'),
col('timestamp'),
]);
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
});
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(true);
});
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
mockGetDraggedColumns.mockReturnValue([
col('state-indicator'),
col('body'),
col('timestamp'),
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(false);
});
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'c' },
} as never);
});
expect(onColumnOrderChange).toHaveBeenCalledWith([
expect.objectContaining({ key: 'b' }),
expect.objectContaining({ key: 'c' }),
expect.objectContaining({ key: 'a' }),
]);
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
'a',
'b',
'c',
]);
});
it('handleDragEnd no-ops when over is null', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
const before = result.current.orderedColumns;
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: null,
} as never);
});
expect(result.current.orderedColumns).toBe(before);
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when active.id equals over.id', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when indices cannot be resolved', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'missing' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('exposes sensors from useSensors', () => {
mockGetDraggedColumns.mockReturnValue([col('a')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.sensors).toBeDefined();
});
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result, rerender } = renderHook(
({ draggedColumns }: { draggedColumns: unknown[] }) =>
useOrderedColumns({
columns: [],
draggedColumns,
onColumnOrderChange: jest.fn(),
}),
{ initialProps: { draggedColumns: [] as unknown[] } },
);
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'a',
'b',
'c',
]);
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
act(() => {
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
});
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'c',
'b',
'a',
]);
});
});

View File

@@ -0,0 +1,433 @@
import {
forwardRef,
memo,
MouseEvent as ReactMouseEvent,
ReactElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { toast } from '@signozhq/sonner';
import {
ColumnDef,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { useTableView } from 'components/Logs/TableView/useTableView';
import Spinner from 'components/Spinner';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import TanStackRowCells from './TanStackRow';
import { TableRecord, TanStackTableRowData } from './types';
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
import { useOrderedColumns } from './useOrderedColumns';
import {
getColumnId,
getColumnMinWidthPx,
resolveColumnTypeRender,
} from './utils';
import '../logsTableVirtuosoScrollbar.scss';
import './styles/TanStackTableView.styles.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function TanStackTableView(
{
isLoading,
isFetching,
onRemoveColumn,
tableViewProps,
infitiyTableProps,
onSetActiveLog,
onClearActiveLog,
activeLog,
}: InfinityTableProps,
forwardedRef,
): JSX.Element {
const { pathname } = useLocation();
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
useImperativeHandle(
forwardedRef,
() => virtuosoRef.current as TableVirtuosoHandle,
[],
);
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
const { activeLog: activeContextLog } = useActiveLog();
// Column definitions (shared with existing logs table)
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
});
// Column order (drag + persisted order)
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
LOCALSTORAGE.LOGS_LIST_COLUMNS,
);
const {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
} = useOrderedColumns({
columns,
draggedColumns,
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
});
// Column sizing (persisted). stored to localStorage.
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
orderedColumns,
);
// don't allow "remove column" when only state-indicator + one data col remain
const isAtMinimumRemovableColumns = useMemo(
() =>
orderedColumns.filter(
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
).length <= 1,
[orderedColumns],
);
// Table data (TanStack row data shape)
// useTableView sends flattened log data. this would not be needed once we move to new log details view
const tableData = useMemo<TanStackTableRowData[]>(
() =>
dataSource
.map((log, rowIndex) => {
const currentLog = tableViewProps.logs[rowIndex];
if (!currentLog) {
return null;
}
return { log, currentLog, rowIndex };
})
.filter(Boolean) as TanStackTableRowData[],
[dataSource, tableViewProps.logs],
);
// TanStack columns + table instance
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
() =>
orderedColumns.map((column, index) => {
const isStateIndicator = column.key === 'state-indicator';
const isExpand = column.key === 'expand';
const isFixedColumn = isStateIndicator || isExpand;
const fixedWidth = isFixedColumn ? 32 : undefined;
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const headerTitle = String(column.title || '');
return {
id: getColumnId(column),
header: headerTitle.replace(/^\w/, (character) =>
character.toUpperCase(),
),
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
minSize: fixedWidth ?? minWidthPx,
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
maxSize: fixedWidth,
cell: ({ row, getValue }): ReactElement | string | number | null => {
if (!column.render) {
return null;
}
return resolveColumnTypeRender(
column.render(
getValue(),
row.original.log,
row.original.rowIndex,
) as ColumnTypeRender<Record<string, unknown>>,
);
},
};
}),
[orderedColumns],
);
const table = useReactTable({
data: tableData,
columns: tanstackColumns,
enableColumnResizing: true,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
onColumnSizingChange: setColumnSizing,
state: {
columnSizing,
},
});
const tableRows = table.getRowModel().rows;
// Infinite-scroll footer UI state
const [loadMoreState, setLoadMoreState] = useState<{
active: boolean;
startCount: number;
}>({
active: false,
startCount: 0,
});
// Map to resolve full log object by id (row highlighting + indicator)
const logsById = useMemo(
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
[tableViewProps.logs],
);
// this is already written in parent. Check if this is needed.
useEffect(() => {
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: activeLogIndex,
align: 'center',
behavior: 'auto',
});
}, [tableRows.length, tableViewProps.activeLogIndex]);
useEffect(() => {
if (!loadMoreState.active) {
return;
}
if (!isFetching || tableRows.length > loadMoreState.startCount) {
setLoadMoreState((prev) =>
prev.active ? { active: false, startCount: prev.startCount } : prev,
);
}
}, [isFetching, loadMoreState, tableRows.length]);
const handleLogCopy = useCallback(
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[pathname, setCopy],
);
const itemContent = useCallback(
(index: number): JSX.Element | null => {
const row = tableRows[index];
if (!row) {
return null;
}
return (
<TanStackRowCells
row={row}
fontSize={tableViewProps.fontSize}
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
isActiveLog={
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
}
isDarkMode={isDarkMode}
onLogCopy={handleLogCopy}
isLogsExplorerPage={isLogsExplorerPage}
/>
);
},
[
activeLog?.id,
handleLogCopy,
isDarkMode,
isLogsExplorerPage,
onClearActiveLog,
onSetActiveLog,
tableRows,
tableViewProps.fontSize,
],
);
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns],
);
const tableHeader = useCallback(() => {
const orderedColumnsById = new Map(
orderedColumns.map((column) => [getColumnId(column), column] as const),
);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext
items={orderedColumnIds}
strategy={horizontalListSortingStrategy}
>
<tr>
{flatHeaders.map((header) => {
const column = orderedColumnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
fontSize={tableViewProps.fontSize}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={onRemoveColumn}
canRemoveColumn={!isAtMinimumRemovableColumns}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
flatHeaders,
handleDragEnd,
hasSingleColumn,
isDarkMode,
orderedColumnIds,
orderedColumns,
onRemoveColumn,
isAtMinimumRemovableColumns,
sensors,
tableViewProps.fontSize,
]);
const handleEndReached = useCallback(
(index: number): void => {
if (!infitiyTableProps?.onEndReached) {
return;
}
setLoadMoreState({
active: true,
startCount: tableRows.length,
});
infitiyTableProps.onEndReached(index);
},
[infitiyTableProps, tableRows.length],
);
if (isLoading) {
return <Spinner height="35px" tip="Getting Logs" />;
}
return (
<div className="tanstack-table-view-wrapper">
<TableVirtuoso
className="logs-table-virtuoso-scroll"
ref={virtuosoRef}
style={infinityDefaultStyles}
data={tableData}
totalCount={tableRows.length}
increaseViewportBy={{ top: 500, bottom: 500 }}
initialTopMostItemIndex={
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
}
context={{ activeLog, activeContextLog, logsById }}
fixedHeaderContent={tableHeader}
itemContent={itemContent}
components={{
Table: ({ style, children }): JSX.Element => (
<TanStackTableStyled style={style}>
<colgroup>
{orderedColumns.map((column, colIndex) => {
const columnId = getColumnId(column);
const isFixedColumn =
column.key === 'expand' || column.key === 'state-indicator';
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const persistedWidth = columnSizing[columnId];
const computedWidth = table.getColumn(columnId)?.getSize();
const effectiveWidth = persistedWidth ?? computedWidth;
if (isFixedColumn) {
return <col key={columnId} className="tanstack-fixed-col" />;
}
// Last data column should stretch to fill remaining space
const isLastColumn = colIndex === orderedColumns.length - 1;
if (isLastColumn) {
return (
<col
key={columnId}
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
/>
);
}
const widthPx =
effectiveWidth != null
? Math.max(effectiveWidth, minWidthPx)
: minWidthPx;
return (
<col
key={columnId}
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
/>
);
})}
</colgroup>
{children}
</TanStackTableStyled>
),
TableRow: TanStackCustomTableRow,
}}
{...(infitiyTableProps?.onEndReached
? { endReached: handleEndReached }
: {})}
/>
{loadMoreState.active && (
<div className="tanstack-load-more-container">
<Spinner height="20px" tip="Getting Logs" />
</div>
)}
</div>
);
},
);
export default memo(TanStackTableView);

View File

@@ -1,4 +1,4 @@
.tanstackHeaderCell {
.tanstack-header-cell {
position: sticky;
top: 0;
z-index: 2;
@@ -10,21 +10,16 @@
);
transition: var(--tanstack-header-transition, none);
&.isDragging {
&.is-dragging {
opacity: 0.85;
}
&.isResizing {
&.is-resizing {
background: var(--l2-background-hover);
}
&:last-child .cursorColResize {
display: none;
border-right: 1px solid var(--l2-border);
}
}
.tanstackHeaderContent {
.tanstack-header-content {
display: flex;
align-items: center;
height: 100%;
@@ -33,20 +28,20 @@
cursor: default;
max-width: 100%;
&.hasResizeControl {
&.has-resize-control {
max-width: calc(100% - 5px);
}
&.hasActionControl {
&.has-action-control {
max-width: calc(100% - 5px);
}
&.hasResizeControl.hasActionControl {
&.has-resize-control.has-action-control {
max-width: calc(100% - 10px);
}
}
.tanstackGripSlot {
.tanstack-grip-slot {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -56,7 +51,7 @@
flex-shrink: 0;
}
.tanstackGripActivator {
.tanstack-grip-activator {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -68,7 +63,7 @@
touch-action: none;
}
.tanstackHeaderActionTrigger {
.tanstack-header-action-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -77,11 +72,9 @@
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
margin-left: auto;
}
.tanstackColumnActionsContent {
.tanstack-column-actions-content {
width: 140px;
padding: 0;
background: var(--l2-background);
@@ -90,7 +83,7 @@
box-shadow: none;
}
.tanstackRemoveColumnAction {
.tanstack-remove-column-action {
display: flex;
align-items: center;
gap: 8px;
@@ -112,19 +105,19 @@
background: var(--l2-background-hover);
color: var(--l2-foreground);
.tanstackRemoveColumnActionIcon {
.tanstack-remove-column-action-icon {
color: var(--l2-foreground);
}
}
}
.tanstackRemoveColumnActionIcon {
.tanstack-remove-column-action-icon {
font-size: 11px;
color: var(--l2-foreground);
opacity: 0.95;
}
.tanstackHeaderCell .cursorColResize {
.tanstack-header-cell .cursor-col-resize {
position: absolute;
top: 0;
right: 0;
@@ -136,11 +129,11 @@
background: transparent;
}
.tanstackHeaderCell.isResizing .cursorColResize {
.tanstack-header-cell.is-resizing .cursor-col-resize {
background: var(--bg-robin-300);
}
.tanstackResizeHandleLine {
.tanstack-resize-handle-line {
position: absolute;
top: 0;
bottom: 0;
@@ -153,7 +146,7 @@
transition: background 120ms ease, width 120ms ease;
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
width: 2px;
background: var(--bg-robin-500);
transition: none;

View File

@@ -0,0 +1,55 @@
.tanstack-table-view-wrapper {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
.tanstack-fixed-col {
width: 32px;
min-width: 32px;
max-width: 32px;
}
.tanstack-filler-col {
width: 100%;
min-width: 0;
}
.tanstack-actions-col {
width: 0;
min-width: 0;
max-width: 0;
}
.tanstack-load-more-container {
width: 100%;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 12px;
flex-shrink: 0;
}
.tanstack-table-virtuoso {
width: 100%;
overflow-x: scroll;
}
.tanstack-fontSize-small {
font-size: 11px;
}
.tanstack-fontSize-medium {
font-size: 13px;
}
.tanstack-fontSize-large {
font-size: 14px;
}
.tanstack-table-foot-loader-cell {
text-align: center;
padding: 8px 0;
}

View File

@@ -0,0 +1,29 @@
import { ColumnSizingState } from '@tanstack/react-table';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { ILog } from 'types/api/logs/log';
export type TableRecord = Record<string, unknown>;
export type LogsTableColumnDef = {
key?: string | number;
title?: string;
render?: (
value: unknown,
record: TableRecord,
index: number,
) => ColumnTypeRender<Record<string, unknown>>;
};
export type OrderedColumn = LogsTableColumnDef & {
key: string | number;
};
export type TanStackTableRowData = {
log: TableRecord;
currentLog: ILog;
rowIndex: number;
};
export type PersistedColumnSizing = {
sizing: ColumnSizingState;
};

View File

@@ -0,0 +1,111 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import { ColumnSizingState } from '@tanstack/react-table';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OrderedColumn, PersistedColumnSizing } from './types';
import { getColumnId } from './utils';
const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 250;
const sanitizeSizing = (input: unknown): ColumnSizingState => {
if (!input || typeof input !== 'object') {
return {};
}
return Object.entries(
input as Record<string, unknown>,
).reduce<ColumnSizingState>((acc, [key, value]) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return acc;
}
acc[key] = value;
return acc;
}, {});
};
const readPersistedColumnSizing = (): ColumnSizingState => {
const rawSizing = getFromLocalstorage(LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING);
if (!rawSizing) {
return {};
}
try {
const parsed = JSON.parse(rawSizing) as
| PersistedColumnSizing
| ColumnSizingState;
const sizing = ('sizing' in parsed
? parsed.sizing
: parsed) as ColumnSizingState;
return sanitizeSizing(sizing);
} catch (error) {
console.error('Failed to parse persisted log column sizing', error);
return {};
}
};
type UseColumnSizingPersistenceResult = {
columnSizing: ColumnSizingState;
setColumnSizing: Dispatch<SetStateAction<ColumnSizingState>>;
};
export const useColumnSizingPersistence = (
orderedColumns: OrderedColumn[],
): UseColumnSizingPersistenceResult => {
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() =>
readPersistedColumnSizing(),
);
const orderedColumnIds = useMemo(
() => orderedColumns.map((column) => getColumnId(column)),
[orderedColumns],
);
useEffect(() => {
if (orderedColumnIds.length === 0) {
return;
}
const validColumnIds = new Set(orderedColumnIds);
const nonResizableColumnIds = new Set(
orderedColumns
.filter(
(column) => column.key === 'expand' || column.key === 'state-indicator',
)
.map((column) => getColumnId(column)),
);
setColumnSizing((previousSizing) => {
const nextSizing = Object.entries(previousSizing).reduce<ColumnSizingState>(
(acc, [columnId, size]) => {
if (!validColumnIds.has(columnId) || nonResizableColumnIds.has(columnId)) {
return acc;
}
acc[columnId] = size;
return acc;
},
{},
);
const hasChanged =
Object.keys(nextSizing).length !== Object.keys(previousSizing).length ||
Object.entries(nextSizing).some(
([columnId, size]) => previousSizing[columnId] !== size,
);
return hasChanged ? nextSizing : previousSizing;
});
}, [orderedColumnIds, orderedColumns]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
const persistedSizing = { sizing: columnSizing };
setToLocalstorage(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
JSON.stringify(persistedSizing),
);
}, COLUMN_SIZING_PERSIST_DEBOUNCE_MS);
return (): void => window.clearTimeout(timeoutId);
}, [columnSizing]);
return { columnSizing, setColumnSizing };
};

View File

@@ -0,0 +1,108 @@
import { useCallback, useMemo } from 'react';
import {
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import { OrderedColumn, TableRecord } from './types';
import { getColumnId } from './utils';
type UseOrderedColumnsProps = {
columns: unknown[];
draggedColumns: unknown[];
onColumnOrderChange: (columns: unknown[]) => void;
};
type UseOrderedColumnsResult = {
orderedColumns: OrderedColumn[];
orderedColumnIds: string[];
hasSingleColumn: boolean;
handleDragEnd: (event: DragEndEvent) => void;
sensors: ReturnType<typeof useSensors>;
};
export const useOrderedColumns = ({
columns,
draggedColumns,
onColumnOrderChange,
}: UseOrderedColumnsProps): UseOrderedColumnsResult => {
const baseColumns = useMemo<OrderedColumn[]>(
() =>
getDraggedColumns<TableRecord>(
columns as never[],
draggedColumns as never[],
).filter(
(column): column is OrderedColumn =>
typeof column.key === 'string' || typeof column.key === 'number',
),
[columns, draggedColumns],
);
const orderedColumns = useMemo(() => {
const stateIndicatorIndex = baseColumns.findIndex(
(column) => column.key === 'state-indicator',
);
if (stateIndicatorIndex <= 0) {
return baseColumns;
}
const pinned = baseColumns[stateIndicatorIndex];
const rest = baseColumns.filter((_, i) => i !== stateIndicatorIndex);
return [pinned, ...rest];
}, [baseColumns]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
// Don't allow moving the state-indicator column
if (String(active.id) === 'state-indicator') {
return;
}
const oldIndex = orderedColumns.findIndex(
(column) => getColumnId(column) === String(active.id),
);
const newIndex = orderedColumns.findIndex(
(column) => getColumnId(column) === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) {
return;
}
const nextColumns = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange(nextColumns as unknown[]);
},
[onColumnOrderChange, orderedColumns],
);
const orderedColumnIds = useMemo(
() => orderedColumns.map((column) => getColumnId(column)),
[orderedColumns],
);
const hasSingleColumn = useMemo(
() =>
orderedColumns.filter((column) => column.key !== 'state-indicator')
.length === 1,
[orderedColumns],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
}),
);
return {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
};
};

View File

@@ -0,0 +1,61 @@
import { cloneElement, isValidElement, ReactElement } from 'react';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { OrderedColumn } from './types';
export const getColumnId = (column: OrderedColumn): string =>
String(column.key);
/** Browser default root font size; TanStack column sizing uses px. */
const REM_PX = 16;
const MIN_WIDTH_OTHER_REM = 12;
const MIN_WIDTH_BODY_REM = 40;
/** When total column count is below this, body column min width is doubled (more horizontal space for few columns). */
export const FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD = 4;
/**
* Minimum width (px) for TanStack column defs + colgroup.
* Design: state/expand 32px; body min 40rem (doubled when fewer than
* {@link FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD} total columns); other columns use rem→px (16px root).
*/
export const getColumnMinWidthPx = (
column: OrderedColumn,
orderedColumns?: OrderedColumn[],
): number => {
const key = String(column.key);
if (key === 'state-indicator' || key === 'expand') {
return 32;
}
if (key === 'body') {
const base = MIN_WIDTH_BODY_REM * REM_PX;
const fewColumns =
orderedColumns != null &&
orderedColumns.length < FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD;
return fewColumns ? base * 1.5 : base;
}
return MIN_WIDTH_OTHER_REM * REM_PX;
};
export const resolveColumnTypeRender = (
rendered: ColumnTypeRender<Record<string, unknown>>,
): ReactElement | string | number | null => {
if (
rendered &&
typeof rendered === 'object' &&
'children' in rendered &&
isValidElement(rendered.children)
) {
const { children, props } = rendered as {
children: ReactElement;
props?: Record<string, unknown>;
};
return cloneElement(children, props || {});
}
if (rendered && typeof rendered === 'object' && isValidElement(rendered)) {
return rendered;
}
return typeof rendered === 'string' || typeof rendered === 'number'
? rendered
: null;
};

View File

@@ -1,28 +1,16 @@
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/sonner';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
// components
import ListLogView from 'components/Logs/ListLogView';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import RawLogView from 'components/Logs/RawLogView';
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import TanStackTable from 'components/TanStackTableView';
import type { TanStackTableHandle } from 'components/TanStackTableView/types';
import { useTableColumns } from 'components/TanStackTableView/useTableColumns';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
@@ -31,7 +19,6 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import APIError from 'types/api/error';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -40,6 +27,7 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
import NoLogs from '../NoLogs/NoLogs';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
import { InfinityWrapperStyled } from './styles';
import TanStackTableView from './TanStackTableView';
import {
convertKeysToColumnFields,
getEmptyLogsListConfig,
@@ -62,10 +50,7 @@ function LogsExplorerList({
isFilterApplied,
handleChangeSelectedView,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const ref = useRef<VirtuosoHandle>(null);
const { activeLogId } = useCopyLogLink();
const {
@@ -99,46 +84,9 @@ function LogsExplorerList({
[options],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
});
const { tableProps } = useTableColumns(logsColumns, {
storageKey: LOCALSTORAGE.LOGS_LIST_COLUMNS,
});
const handleRemoveColumn = useCallback(
(columnId: string): void => {
tableProps.onRemoveColumn(columnId);
config.addColumn?.onRemove?.(columnId);
},
[tableProps, config.addColumn],
);
const makeOnLogCopy = useCallback(
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[pathname, setCopy],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
virtuosoRef: ref,
});
useEffect(() => {
@@ -207,46 +155,25 @@ function LogsExplorerList({
if (options.format === 'table') {
return (
<TanStackTable
ref={ref as React.Ref<TanStackTableHandle>}
{...tableProps}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
onRemoveColumn={handleRemoveColumn}
data={logs}
isLoading={isLoading || isFetching}
loadingTip="Getting Logs"
onEndReached={onEndReached}
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
'--row-hover-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
} as CSSProperties)
}
onRowClick={(log): void => {
handleSetActiveLog(log);
<TanStackTableView
ref={ref}
isLoading={isLoading}
isFetching={isFetching}
tableViewProps={{
logs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
infitiyTableProps={{ onEndReached }}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
);
}
@@ -271,7 +198,7 @@ function LogsExplorerList({
<OverlayScrollbar isVirtuoso>
<Virtuoso
key={activeLogIndex || 'logs-virtuoso'}
ref={ref as React.Ref<VirtuosoHandle>}
ref={ref}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={logs}
endReached={onEndReached}
@@ -292,13 +219,12 @@ function LogsExplorerList({
onEndReached,
getItemContent,
isFetching,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
tableProps,
handleRemoveColumn,
isDarkMode,
makeOnLogCopy,
config.addColumn?.onRemove,
]);
const isTraceToLogsNavigation = useMemo(() => {

View File

@@ -0,0 +1,38 @@
.logs-table-virtuoso-scroll {
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
.lightMode .logs-table-virtuoso-scroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}

View File

@@ -677,6 +677,18 @@ function NewWidget({
queryType: currentQuery.queryType,
isNewPanel,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
...(currentQuery.queryType === EQueryType.CLICKHOUSE && {
clickhouseQueryCount: currentQuery.clickhouse_sql.length,
clickhouseQueries: currentQuery.clickhouse_sql.map((q) => ({
name: q.name,
query: (q.query ?? '')
.replace(/--[^\n]*/g, '') // strip line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments
.replace(/'(?:[^'\\]|\\.|'')*'/g, "'?'") // replace single-quoted strings (handles \' and '' escapes)
.replace(/\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, '?'), // replace numeric literals (int, float, scientific)
disabled: q.disabled,
})),
}),
});
setSaveModal(true);
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,11 +1,9 @@
import { useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
type ScrollToIndexHandle = Pick<VirtuosoHandle, 'scrollToIndex'>;
type UseScrollToLogParams = {
logs: Array<{ id: string }>;
virtuosoRef: React.RefObject<ScrollToIndexHandle | null>;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
};
function useScrollToLog({

View File

@@ -10,6 +10,26 @@ import (
)
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/credentials", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetConnectionCredentials),
handler.OpenAPIDef{
ID: "GetConnectionCredentials",
Tags: []string{"cloudintegration"},
Summary: "Get connection credentials",
Description: "This endpoint retrieves the connection credentials required for integration",
Request: nil,
RequestContentType: "application/json",
Response: new(citypes.Credentials),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
handler.OpenAPIDef{
@@ -17,11 +37,11 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Tags: []string{"cloudintegration"},
Summary: "Create account",
Description: "This endpoint creates a new cloud integration account for the specified cloud provider",
Request: new(citypes.PostableConnectionArtifact),
Request: new(citypes.PostableAccount),
RequestContentType: "application/json",
Response: new(citypes.GettableAccountWithArtifact),
Response: new(citypes.GettableAccountWithConnectionArtifact),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
@@ -59,7 +79,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccount),
Response: new(citypes.Account),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
@@ -118,6 +138,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Summary: "List services metadata",
Description: "This endpoint lists the services metadata for the specified cloud provider",
Request: nil,
RequestQuery: new(citypes.ListServicesMetadataParams),
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
ResponseContentType: "application/json",
@@ -138,8 +159,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Summary: "Get service",
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestQuery: new(citypes.GetServiceParams),
RequestContentType: "",
Response: new(citypes.GettableService),
Response: new(citypes.Service),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -150,7 +172,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
handler.OpenAPIDef{
ID: "UpdateService",
@@ -179,9 +201,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
Request: new(citypes.PostableAgentCheckIn),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
Response: new(citypes.GettableAgentCheckIn),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -199,9 +221,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
Request: new(citypes.PostableAgentCheckIn),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
Response: new(citypes.GettableAgentCheckIn),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -0,0 +1,33 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/infra_monitoring/hosts", handler.New(
provider.authZ.ViewAccess(provider.infraMonitoringHandler.HostsList),
handler.OpenAPIDef{
ID: "HostsList",
Tags: []string{"infra-monitoring"},
Summary: "List Hosts for Infra Monitoring",
Description: "Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary.",
Request: new(inframonitoringtypes.HostsListRequest),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.HostsListResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
@@ -47,6 +48,7 @@ type provider struct {
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
@@ -73,6 +75,7 @@ func NewFactory(
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
@@ -102,6 +105,7 @@ func NewFactory(
dashboardModule,
dashboardHandler,
metricsExplorerHandler,
infraMonitoringHandler,
gatewayHandler,
fieldsHandler,
authzHandler,
@@ -133,6 +137,7 @@ func newProvider(
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
@@ -162,6 +167,7 @@ func newProvider(
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
@@ -228,6 +234,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addInfraMonitoringRoutes(router); err != nil {
return err
}
if err := provider.addGatewayRoutes(router); err != nil {
return err
}

View File

@@ -63,6 +63,7 @@ type RetryConfig struct {
func newConfig() factory.Config {
return Config{
Provider: "noop",
BufferSize: 1000,
BatchSize: 100,
FlushInterval: time.Second,

View File

@@ -10,37 +10,42 @@ import (
)
type Module interface {
GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Credentials, error)
CreateAccount(ctx context.Context, account *citypes.Account) error
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
// ListAccounts lists accounts where agent is connected
ListAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
// UpdateAccount updates the cloud integration account for a specific organization.
UpdateAccount(ctx context.Context, account *citypes.Account) error
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) error
// GetConnectionArtifact returns cloud provider specific connection information,
// client side handles how this information is shown
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.GetConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// ListServicesMetadata returns the list of services metadata for a cloud provider attached with the integrationID.
// This just returns a summary of the service and not the whole service definition
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// ListServicesMetadata returns the list of supported services' metadata for a cloud provider with optional filtering for a specific integration
// This just returns a summary of the service and not the whole service definition.
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// GetService returns service definition details for a serviceID. This returns config and
// other details required to show in service details page on web client.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
// GetService returns service definition details for a serviceID. This optionally returns the service config
// for integrationID if provided.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType) (*citypes.Service, error)
// CreateService creates a new service for a cloud integration account.
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// UpdateService updates cloud integration service
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// AgentCheckIn is called by agent to send heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
@@ -52,7 +57,22 @@ type Module interface {
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
}
type CloudProviderModule interface {
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.GetConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// ListServiceDefinitions returns all service definitions for this cloud provider.
ListServiceDefinitions(ctx context.Context) ([]*citypes.ServiceDefinition, error)
// GetServiceDefinition returns the service definition for the given service ID.
GetServiceDefinition(ctx context.Context, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error)
// BuildIntegrationConfig compiles the provider-specific integration config from the account
// and list of configured services. This is the config returned to the agent on check-in.
BuildIntegrationConfig(ctx context.Context, account *citypes.Account, services []*citypes.StorableCloudIntegrationService) (*citypes.ProviderIntegrationConfig, error)
}
type Handler interface {
GetConnectionCredentials(http.ResponseWriter, *http.Request)
CreateAccount(http.ResponseWriter, *http.Request)
ListAccounts(http.ResponseWriter, *http.Request)
GetAccount(http.ResponseWriter, *http.Request)

View File

@@ -447,9 +447,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ApplicationELB"
"namespace": "AWS/ApplicationELB"
}
]
}

View File

@@ -171,14 +171,14 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ApiGateway"
"namespace": "AWS/ApiGateway"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "API-Gateway",
"filterPattern": ""

View File

@@ -374,9 +374,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/DynamoDB"
"namespace": "AWS/DynamoDB"
}
]
}

View File

@@ -495,12 +495,12 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/EC2"
"namespace": "AWS/EC2"
},
{
"Namespace": "CWAgent"
"namespace": "CWAgent"
}
]
}

View File

@@ -823,17 +823,17 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ECS"
"namespace": "AWS/ECS"
},
{
"Namespace": "ECS/ContainerInsights"
"namespace": "ECS/ContainerInsights"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/ecs",
"filterPattern": ""

View File

@@ -2702,17 +2702,17 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/EKS"
"namespace": "AWS/EKS"
},
{
"Namespace": "ContainerInsights"
"namespace": "ContainerInsights"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/aws/containerinsights",
"filterPattern": ""

View File

@@ -1934,9 +1934,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ElastiCache"
"namespace": "AWS/ElastiCache"
}
]
}

View File

@@ -271,14 +271,14 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/Lambda"
"namespace": "AWS/Lambda"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/aws/lambda",
"filterPattern": ""

View File

@@ -1070,9 +1070,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/Kafka"
"namespace": "AWS/Kafka"
}
]
}

View File

@@ -775,14 +775,14 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/RDS"
"namespace": "AWS/RDS"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/aws/rds",
"filterPattern": ""

View File

@@ -39,7 +39,7 @@
"telemetryCollectionStrategy": {
"aws": {
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "x/signoz/forwarder",
"filterPattern": ""

View File

@@ -110,9 +110,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/SNS"
"namespace": "AWS/SNS"
}
]
}

View File

@@ -230,9 +230,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/SQS"
"namespace": "AWS/SQS"
}
]
}

View File

@@ -12,6 +12,10 @@ func NewHandler() cloudintegration.Handler {
return &handler{}
}
func (handler *handler) GetConnectionCredentials(http.ResponseWriter, *http.Request) {
panic("unimplemented")
}
func (handler *handler) CreateAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")

View File

@@ -34,6 +34,25 @@ func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, p
return account, nil
}
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(account).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
}
return account, nil
}
func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
var accounts []*cloudintegrationtypes.StorableCloudIntegration
err := store.
@@ -96,25 +115,6 @@ func (store *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, pr
return err
}
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(account).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
}
return account, nil
}
func (store *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
err := store.
@@ -172,3 +172,9 @@ func (store *store) UpdateService(ctx context.Context, service *cloudintegration
Exec(ctx)
return err
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.store.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)
})
}

View File

@@ -0,0 +1,33 @@
package inframonitoring
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
type Config struct {
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
}
type TelemetryStoreConfig struct {
Threads int `mapstructure:"threads"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("inframonitoring"), newConfig)
}
func newConfig() factory.Config {
return Config{
TelemetryStore: TelemetryStoreConfig{
Threads: 8,
},
}
}
func (c Config) Validate() error {
if c.TelemetryStore.Threads <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "inframonitoring.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
}
return nil
}

View File

@@ -0,0 +1,274 @@
package implinframonitoring
import (
"context"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
expression := ""
if filter != nil {
expression = strings.TrimSpace(filter.Expression)
}
if expression == "" {
return sqlbuilder.NewWhereClause(), nil
}
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
for idx := range whereClauseSelectors {
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
}
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, whereClauseSelectors)
if err != nil {
return nil, err
}
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: m.logger,
FieldMapper: m.fieldMapper,
ConditionBuilder: m.condBuilder,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FieldKeys: keys,
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),
}
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
if err != nil {
return nil, err
}
if whereClause == nil || whereClause.WhereClause == nil {
return sqlbuilder.NewWhereClause(), nil
}
return whereClause.WhereClause, nil
}
// getMetricsExistenceAndEarliestTime checks which of the given metric names have been
// reported. It returns a list of missing metrics (those not found or with zero count)
// and the earliest first-reported timestamp across all present metrics.
// When all metrics are missing, minFirstReportedUnixMilli is 0.
// TODO(nikhilmantri0902, srikanthccv): This method was designed this way because querier errors if any of the metrics
// in the querier list was never sent, the QueryRange call throws not found error. Modify this method, if QueryRange
// behaviour changes towards this.
func (m *module) getMetricsExistenceAndEarliestTime(ctx context.Context, metricNames []string) ([]string, uint64, error) {
if len(metricNames) == 0 {
return nil, 0, nil
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "count(*) AS cnt", "min(first_reported_unix_milli) AS min_first_reported")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
type metricInfo struct {
count uint64
minFirstReported uint64
}
found := make(map[string]metricInfo, len(metricNames))
for rows.Next() {
var name string
var cnt, minFR uint64
if err := rows.Scan(&name, &cnt, &minFR); err != nil {
return nil, 0, err
}
found[name] = metricInfo{count: cnt, minFirstReported: minFR}
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var missingMetrics []string
var globalMinFirstReported uint64
for _, name := range metricNames {
info, ok := found[name]
if !ok || info.count == 0 {
missingMetrics = append(missingMetrics, name)
continue
}
if globalMinFirstReported == 0 || info.minFirstReported < globalMinFirstReported {
globalMinFirstReported = info.minFirstReported
}
}
return missingMetrics, globalMinFirstReported, nil
}
// getMetadata fetches the latest values of additionalCols for each unique combination of groupBy keys,
// within the given time range and metric names. It uses argMax(tuple(...), unix_milli) to ensure
// we always pick attribute values from the latest timestamp for each group.
// The returned map has a composite key of groupBy column values joined by "\x00" (null byte),
// mapping to a flat map of attr_name -> attr_value (includes both groupBy and additional cols).
func (m *module) getMetadata(
ctx context.Context,
metricNames []string,
groupBy []qbtypes.GroupByKey,
additionalCols []string,
filter *qbtypes.Filter,
startMs, endMs int64,
) (map[string]map[string]string, error) {
if len(metricNames) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricNames must not be empty")
}
if len(groupBy) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "groupBy must not be empty")
}
// Pick the optimal timeseries table based on time range; also get adjusted start.
adjustedStart, adjustedEnd, distributedTableName, _ := telemetrymetrics.WhichTSTableToUse(
uint64(startMs), uint64(endMs), nil,
)
// Build a fingerprint subquery against the samples table using the original
// (non-adjusted) time range. The time_series tables are ReplacingMergeTrees
// with bucketed granularity, so WhichTSTableToUse widens the window — this
// subquery restricts to fingerprints actually active in the requested range.
samplesTableName := telemetrymetrics.WhichSamplesTableToUse(
uint64(startMs), uint64(endMs),
metrictypes.UnspecifiedType,
metrictypes.TimeAggregationUnspecified,
nil,
)
localSamplesTable := strings.TrimPrefix(samplesTableName, "distributed_")
fpSB := sqlbuilder.NewSelectBuilder()
fpSB.Select("DISTINCT fingerprint")
fpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localSamplesTable))
fpSB.Where(
fpSB.In("metric_name", sqlbuilder.List(metricNames)),
fpSB.GE("unix_milli", startMs),
fpSB.L("unix_milli", endMs),
)
// Flatten groupBy keys to string names for SQL expressions and result scanning.
groupByCols := make([]string, len(groupBy))
for i, key := range groupBy {
groupByCols[i] = key.Name
}
allCols := append(groupByCols, additionalCols...)
// --- Build inner query ---
innerSB := sqlbuilder.NewSelectBuilder()
// Inner SELECT columns: JSONExtractString for each groupBy col + argMax(tuple(...)) for additional cols
innerSelectCols := make([]string, 0, len(groupByCols)+1)
for _, col := range groupByCols {
innerSelectCols = append(innerSelectCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", innerSB.Var(col), quoteIdentifier(col)),
)
}
// Build the argMax(tuple(...), unix_milli) expression for all additional cols
if len(additionalCols) > 0 {
tupleArgs := make([]string, 0, len(additionalCols))
for _, col := range additionalCols {
tupleArgs = append(tupleArgs, fmt.Sprintf("JSONExtractString(labels, %s)", innerSB.Var(col)))
}
innerSelectCols = append(innerSelectCols,
fmt.Sprintf("argMax(tuple(%s), unix_milli) AS latest_attrs", strings.Join(tupleArgs, ", ")),
)
}
innerSB.Select(innerSelectCols...)
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
innerSB.Where(
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
innerSB.GE("unix_milli", adjustedStart),
innerSB.L("unix_milli", adjustedEnd),
fmt.Sprintf("fingerprint IN (%s)", innerSB.Var(fpSB)),
)
// Apply optional filter expression
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
if err != nil {
return nil, err
}
if filterClause != nil {
innerSB.AddWhereClause(filterClause)
}
}
groupByAliases := make([]string, 0, len(groupByCols))
for _, col := range groupByCols {
groupByAliases = append(groupByAliases, quoteIdentifier(col))
}
innerSB.GroupBy(groupByAliases...)
innerQuery, innerArgs := innerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
// --- Build outer query ---
// Outer SELECT columns: groupBy cols directly + tupleElement(latest_attrs, N) for each additionalCol
outerSelectCols := make([]string, 0, len(allCols))
for _, col := range groupByCols {
outerSelectCols = append(outerSelectCols, quoteIdentifier(col))
}
for i, col := range additionalCols {
outerSelectCols = append(outerSelectCols,
fmt.Sprintf("tupleElement(latest_attrs, %d) AS %s", i+1, quoteIdentifier(col)),
)
}
outerSB := sqlbuilder.NewSelectBuilder()
outerSB.Select(outerSelectCols...)
outerSB.From(fmt.Sprintf("(%s)", innerQuery))
outerQuery, _ := outerSB.BuildWithFlavor(sqlbuilder.ClickHouse)
// All ? params are in innerArgs; outer query introduces none of its own.
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, outerQuery, innerArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]map[string]string)
for rows.Next() {
row := make([]string, len(allCols))
scanPtrs := make([]any, len(row))
for i := range row {
scanPtrs[i] = &row[i]
}
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
compositeKey := compositeKeyFromList(row[:len(groupByCols)])
attrMap := make(map[string]string, len(allCols))
for i, col := range allCols {
attrMap[col] = row[i]
}
result[compositeKey] = attrMap
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -0,0 +1,48 @@
package implinframonitoring
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
module inframonitoring.Module
}
// NewHandler returns an inframonitoring.Handler implementation.
func NewHandler(m inframonitoring.Module) inframonitoring.Handler {
return &handler{
module: m,
}
}
func (h *handler) HostsList(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.HostsListRequest
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse request body"))
return
}
result, err := h.module.HostsList(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -0,0 +1,292 @@
package implinframonitoring
import (
"fmt"
"sort"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// quoteIdentifier wraps s in backticks for use as a ClickHouse identifier,
// escaping any embedded backticks by doubling them.
func quoteIdentifier(s string) string {
return fmt.Sprintf("`%s`", strings.ReplaceAll(s, "`", "``"))
}
type rankedGroup struct {
labels map[string]string
value float64
}
func isKeyInGroupByAttrs(groupByAttrs []qbtypes.GroupByKey, key string) bool {
for _, groupBy := range groupByAttrs {
if groupBy.Name == key {
return true
}
}
return false
}
func mergeFilterExpressions(queryFilterExpr, reqFilterExpr string) string {
queryFilterExpr = strings.TrimSpace(queryFilterExpr)
reqFilterExpr = strings.TrimSpace(reqFilterExpr)
if queryFilterExpr == "" {
return reqFilterExpr
}
if reqFilterExpr == "" {
return queryFilterExpr
}
return fmt.Sprintf("(%s) AND (%s)", queryFilterExpr, reqFilterExpr)
}
// compositeKeyFromList builds a composite key by joining the given parts
// with a null byte separator. This is the canonical way to construct
// composite keys for group identification across the infra monitoring module.
func compositeKeyFromList(parts []string) string {
return strings.Join(parts, "\x00")
}
// compositeKeyFromLabels builds a composite key from a label map by extracting
// the value for each groupBy key in order and joining them via compositeKeyFromList.
func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByKey) string {
parts := make([]string, len(groupBy))
for i, key := range groupBy {
parts[i] = labels[key.Name]
}
return compositeKeyFromList(parts)
}
// parseAndSortGroups extracts group label maps from a ScalarData response and
// sorts them by the ranking query's aggregation value.
func parseAndSortGroups(
resp *qbtypes.QueryRangeResponse,
rankingQueryName string,
groupBy []qbtypes.GroupByKey,
direction qbtypes.OrderDirection,
) []rankedGroup {
if resp == nil || len(resp.Data.Results) == 0 {
return nil
}
// Find the ScalarData that contains the ranking column.
var sd *qbtypes.ScalarData
for _, r := range resp.Data.Results {
candidate, ok := r.(*qbtypes.ScalarData)
if !ok || candidate == nil {
continue
}
for _, col := range candidate.Columns {
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
sd = candidate
break
}
}
if sd != nil {
break
}
}
if sd == nil || len(sd.Data) == 0 {
return nil
}
groupColIndices := make(map[string]int)
rankingColIdx := -1
for i, col := range sd.Columns {
if col.Type == qbtypes.ColumnTypeGroup {
groupColIndices[col.Name] = i
}
if col.Type == qbtypes.ColumnTypeAggregation && col.QueryName == rankingQueryName {
rankingColIdx = i
}
}
if rankingColIdx == -1 {
return nil
}
groups := make([]rankedGroup, 0, len(sd.Data))
for _, row := range sd.Data {
labels := make(map[string]string, len(groupBy))
for _, key := range groupBy {
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
labels[key.Name] = fmt.Sprintf("%v", row[idx])
}
}
var value float64
if rankingColIdx < len(row) {
if v, ok := row[rankingColIdx].(float64); ok {
value = v
}
}
groups = append(groups, rankedGroup{labels: labels, value: value})
}
sort.Slice(groups, func(i, j int) bool {
if direction == qbtypes.OrderDirectionAsc {
return groups[i].value < groups[j].value
}
return groups[i].value > groups[j].value
})
return groups
}
// paginateWithBackfill returns the page of groups for [offset, offset+limit).
// The virtual sorted list is: metric-ranked groups first, then metadata-only
// groups (those in metadataMap but not in metric results) sorted alphabetically.
func paginateWithBackfill(
metricGroups []rankedGroup,
metadataMap map[string]map[string]string,
groupBy []qbtypes.GroupByKey,
offset, limit int,
) []map[string]string {
metricKeySet := make(map[string]bool, len(metricGroups))
for _, g := range metricGroups {
metricKeySet[compositeKeyFromLabels(g.labels, groupBy)] = true
}
metadataOnlyKeys := make([]string, 0)
for compositeKey := range metadataMap {
if !metricKeySet[compositeKey] {
metadataOnlyKeys = append(metadataOnlyKeys, compositeKey)
}
}
sort.Strings(metadataOnlyKeys)
totalMetric := len(metricGroups)
totalAll := totalMetric + len(metadataOnlyKeys)
end := offset + limit
if end > totalAll {
end = totalAll
}
if offset >= totalAll {
return nil
}
pageGroups := make([]map[string]string, 0, end-offset)
for i := offset; i < end; i++ {
if i < totalMetric {
pageGroups = append(pageGroups, metricGroups[i].labels)
} else {
compositeKey := metadataOnlyKeys[i-totalMetric]
attrs := metadataMap[compositeKey]
labels := make(map[string]string, len(groupBy))
for _, key := range groupBy {
labels[key.Name] = attrs[key.Name]
}
pageGroups = append(pageGroups, labels)
}
}
return pageGroups
}
// buildFullQueryRequest creates a QueryRangeRequest for all metrics,
// restricted to the given page of groups via an IN filter.
// Accepts primitive fields so it can be reused across different v2 APIs
// (hosts, pods, etc.).
func buildFullQueryRequest(
start int64,
end int64,
filterExpr string,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
tableListQuery *qbtypes.QueryRangeRequest,
) *qbtypes.QueryRangeRequest {
groupValues := make(map[string][]string)
for _, labels := range pageGroups {
for k, v := range labels {
groupValues[k] = append(groupValues[k], v)
}
}
inClauses := make([]string, 0, len(groupValues))
for key, values := range groupValues {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = fmt.Sprintf("'%s'", v)
}
inClauses = append(inClauses, fmt.Sprintf("%s IN (%s)", key, strings.Join(quoted, ", ")))
}
inFilterExpr := strings.Join(inClauses, " AND ")
fullReq := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(tableListQuery.CompositeQuery.Queries)),
},
}
for _, envelope := range tableListQuery.CompositeQuery.Queries {
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
merged := mergeFilterExpressions(existingExpr, filterExpr)
merged = mergeFilterExpressions(merged, inFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(groupBy)
}
fullReq.CompositeQuery.Queries = append(fullReq.CompositeQuery.Queries, copied)
}
return fullReq
}
// parseFullQueryResponse extracts per-group metric values from the full
// composite query response. Returns compositeKey -> (queryName -> value).
// Each enabled query/formula produces its own ScalarData entry in Results,
// so we iterate over all of them and merge metrics per composite key.
func parseFullQueryResponse(
resp *qbtypes.QueryRangeResponse,
groupBy []qbtypes.GroupByKey,
) map[string]map[string]float64 {
result := make(map[string]map[string]float64)
if resp == nil || len(resp.Data.Results) == 0 {
return result
}
for _, r := range resp.Data.Results {
sd, ok := r.(*qbtypes.ScalarData)
if !ok || sd == nil {
continue
}
groupColIndices := make(map[string]int)
aggCols := make(map[int]string) // col index -> query name
for i, col := range sd.Columns {
if col.Type == qbtypes.ColumnTypeGroup {
groupColIndices[col.Name] = i
}
if col.Type == qbtypes.ColumnTypeAggregation {
aggCols[i] = col.QueryName
}
}
for _, row := range sd.Data {
labels := make(map[string]string, len(groupBy))
for _, key := range groupBy {
if idx, ok := groupColIndices[key.Name]; ok && idx < len(row) {
labels[key.Name] = fmt.Sprintf("%v", row[idx])
}
}
compositeKey := compositeKeyFromLabels(labels, groupBy)
if result[compositeKey] == nil {
result[compositeKey] = make(map[string]float64)
}
for idx, queryName := range aggCols {
if idx < len(row) {
if v, ok := row[idx].(float64); ok {
result[compositeKey][queryName] = v
}
}
}
}
}
return result
}

View File

@@ -0,0 +1,283 @@
package implinframonitoring
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func groupByKey(name string) qbtypes.GroupByKey {
return qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
}
}
func TestIsKeyInGroupByAttrs(t *testing.T) {
tests := []struct {
name string
groupByAttrs []qbtypes.GroupByKey
key string
expectedFound bool
}{
{
name: "key present in single-element list",
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
key: "host.name",
expectedFound: true,
},
{
name: "key present in multi-element list",
groupByAttrs: []qbtypes.GroupByKey{
groupByKey("host.name"),
groupByKey("os.type"),
groupByKey("k8s.cluster.name"),
},
key: "os.type",
expectedFound: true,
},
{
name: "key at last position",
groupByAttrs: []qbtypes.GroupByKey{
groupByKey("host.name"),
groupByKey("os.type"),
},
key: "os.type",
expectedFound: true,
},
{
name: "key not in list",
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
key: "os.type",
expectedFound: false,
},
{
name: "empty group by list",
groupByAttrs: []qbtypes.GroupByKey{},
key: "host.name",
expectedFound: false,
},
{
name: "nil group by list",
groupByAttrs: nil,
key: "host.name",
expectedFound: false,
},
{
name: "empty key string",
groupByAttrs: []qbtypes.GroupByKey{groupByKey("host.name")},
key: "",
expectedFound: false,
},
{
name: "empty key matches empty-named group by key",
groupByAttrs: []qbtypes.GroupByKey{groupByKey("")},
key: "",
expectedFound: true,
},
{
name: "partial match does not count",
groupByAttrs: []qbtypes.GroupByKey{
groupByKey("host"),
},
key: "host.name",
expectedFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isKeyInGroupByAttrs(tt.groupByAttrs, tt.key)
if got != tt.expectedFound {
t.Errorf("isKeyInGroupByAttrs(%v, %q) = %v, want %v",
tt.groupByAttrs, tt.key, got, tt.expectedFound)
}
})
}
}
func TestMergeFilterExpressions(t *testing.T) {
tests := []struct {
name string
queryFilterExpr string
reqFilterExpr string
expected string
}{
{
name: "both non-empty",
queryFilterExpr: "cpu > 50",
reqFilterExpr: "host.name = 'web-1'",
expected: "(cpu > 50) AND (host.name = 'web-1')",
},
{
name: "query empty, req non-empty",
queryFilterExpr: "",
reqFilterExpr: "host.name = 'web-1'",
expected: "host.name = 'web-1'",
},
{
name: "query non-empty, req empty",
queryFilterExpr: "cpu > 50",
reqFilterExpr: "",
expected: "cpu > 50",
},
{
name: "both empty",
queryFilterExpr: "",
reqFilterExpr: "",
expected: "",
},
{
name: "whitespace-only query treated as empty",
queryFilterExpr: " ",
reqFilterExpr: "host.name = 'web-1'",
expected: "host.name = 'web-1'",
},
{
name: "whitespace-only req treated as empty",
queryFilterExpr: "cpu > 50",
reqFilterExpr: " ",
expected: "cpu > 50",
},
{
name: "both whitespace-only",
queryFilterExpr: " ",
reqFilterExpr: " ",
expected: "",
},
{
name: "leading/trailing whitespace trimmed before merge",
queryFilterExpr: " cpu > 50 ",
reqFilterExpr: " mem < 80 ",
expected: "(cpu > 50) AND (mem < 80)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeFilterExpressions(tt.queryFilterExpr, tt.reqFilterExpr)
if got != tt.expected {
t.Errorf("mergeFilterExpressions(%q, %q) = %q, want %q",
tt.queryFilterExpr, tt.reqFilterExpr, got, tt.expected)
}
})
}
}
func TestCompositeKeyFromList(t *testing.T) {
tests := []struct {
name string
parts []string
expected string
}{
{
name: "single part",
parts: []string{"web-1"},
expected: "web-1",
},
{
name: "multiple parts joined with null separator",
parts: []string{"web-1", "linux", "us-east"},
expected: "web-1\x00linux\x00us-east",
},
{
name: "empty slice returns empty string",
parts: []string{},
expected: "",
},
{
name: "nil slice returns empty string",
parts: nil,
expected: "",
},
{
name: "parts with empty strings",
parts: []string{"web-1", "", "us-east"},
expected: "web-1\x00\x00us-east",
},
{
name: "all empty strings",
parts: []string{"", ""},
expected: "\x00",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := compositeKeyFromList(tt.parts)
if got != tt.expected {
t.Errorf("compositeKeyFromList(%v) = %q, want %q",
tt.parts, got, tt.expected)
}
})
}
}
func TestCompositeKeyFromLabels(t *testing.T) {
tests := []struct {
name string
labels map[string]string
groupBy []qbtypes.GroupByKey
expected string
}{
{
name: "single group-by key",
labels: map[string]string{"host.name": "web-1"},
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
expected: "web-1",
},
{
name: "multiple group-by keys joined with null separator",
labels: map[string]string{
"host.name": "web-1",
"os.type": "linux",
},
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
expected: "web-1\x00linux",
},
{
name: "missing label yields empty segment",
labels: map[string]string{"host.name": "web-1"},
groupBy: []qbtypes.GroupByKey{groupByKey("host.name"), groupByKey("os.type")},
expected: "web-1\x00",
},
{
name: "empty labels map",
labels: map[string]string{},
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
expected: "",
},
{
name: "empty group-by slice",
labels: map[string]string{"host.name": "web-1"},
groupBy: []qbtypes.GroupByKey{},
expected: "",
},
{
name: "nil labels map",
labels: nil,
groupBy: []qbtypes.GroupByKey{groupByKey("host.name")},
expected: "",
},
{
name: "order matches group-by order, not map iteration order",
labels: map[string]string{
"z": "last",
"a": "first",
"m": "middle",
},
groupBy: []qbtypes.GroupByKey{groupByKey("a"), groupByKey("m"), groupByKey("z")},
expected: "first\x00middle\x00last",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := compositeKeyFromLabels(tt.labels, tt.groupBy)
if got != tt.expected {
t.Errorf("compositeKeyFromLabels(%v, %v) = %q, want %q",
tt.labels, tt.groupBy, got, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,492 @@
package implinframonitoring
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
const (
hostNameAttrKey = "host.name"
)
// Helper group-by key used across all queries.
var hostNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: hostNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
var hostsTableMetricNamesList = []string{
"system.cpu.time",
"system.memory.usage",
"system.cpu.load_average.15m",
"system.filesystem.usage",
}
var hostAttrKeysForMetadata = []string{
"os.type",
}
// orderByToHostsQueryNames maps the orderBy column to the query/formula names
// from HostsTableListQuery used for ranking host groups.
var orderByToHostsQueryNames = map[string][]string{
inframonitoringtypes.HostsOrderByCPU: {"A", "B", "F1"},
inframonitoringtypes.HostsOrderByMemory: {"C", "D", "F2"},
inframonitoringtypes.HostsOrderByWait: {"E", "F", "F3"},
inframonitoringtypes.HostsOrderByDiskUsage: {"H", "I", "F4"},
inframonitoringtypes.HostsOrderByLoad15: {"G"},
}
func (m *module) newHostsTableListQuery() *qbtypes.QueryRangeRequest {
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
// Query A: CPU usage logic (non-idle)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.cpu.time",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "state != 'idle'",
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Query B: CPU usage (all states)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.cpu.time",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Formula F1: CPU Usage (%)
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "F1",
Expression: "A/B",
Legend: "CPU Usage (%)",
Disabled: false,
},
},
// Query C: Memory usage (state = used)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.memory.usage",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "state = 'used'",
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Query D: Memory usage (all states)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.memory.usage",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Formula F2: Memory Usage (%)
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "F2",
Expression: "C/D",
Legend: "Memory Usage (%)",
Disabled: false,
},
},
// Query E: CPU Wait time (state = wait)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "E",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.cpu.time",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "state = 'wait'",
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Query F: CPU time (all states)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "F",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.cpu.time",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Formula F3: CPU Wait Time (%)
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "F3",
Expression: "E/F",
Legend: "CPU Wait Time (%)",
Disabled: false,
},
},
// Query G: Load15
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "G",
Signal: telemetrytypes.SignalMetrics,
Legend: "CPU Load Average (15m)",
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.cpu.load_average.15m",
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: false,
},
},
// Query H: Filesystem Usage (state = used)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "H",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.filesystem.usage",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "state = 'used'",
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Query I: Filesystem Usage (all states)
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "I",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.filesystem.usage",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{hostNameGroupByKey},
Disabled: true,
},
},
// Formula F4: Disk Usage (%)
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "F4",
Expression: "H/I",
Legend: "Disk Usage (%)",
Disabled: false,
},
},
},
},
}
}
// getTopHostGroups runs a ranking query for the ordering metric, sorts the
// results, paginates, and backfills from metadataMap when the page extends
// past the metric-ranked groups.
func (m *module) getTopHostGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.HostsListRequest,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
queryNamesForOrderBy := orderByToHostsQueryNames[orderByKey]
// The last entry is the formula/query whose value we sort by.
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newHostsTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
// applyHostsActiveStatusFilter modifies req.Filter.Expression to include an IN/NOT IN
// clause based on FilterByStatus and the set of active hosts.
// Returns true if the caller should short-circuit with an empty result (eg. ACTIVE
// requested but no hosts are active).
func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.HostsListRequest, activeHostsMap map[string]bool) (shouldShortCircuit bool) {
if req.FilterByStatus != inframonitoringtypes.HostStatusActive && req.FilterByStatus != inframonitoringtypes.HostStatusInactive {
return false
}
activeHosts := make([]string, 0, len(activeHostsMap))
for host := range activeHostsMap {
activeHosts = append(activeHosts, fmt.Sprintf("'%s'", host))
}
if len(activeHosts) == 0 {
return req.FilterByStatus == inframonitoringtypes.HostStatusActive
}
op := "IN"
if req.FilterByStatus == inframonitoringtypes.HostStatusInactive {
op = "NOT IN"
}
if req.Filter == nil {
req.Filter = &qbtypes.Filter{}
}
statusClause := fmt.Sprintf("%s %s (%s)", hostNameAttrKey, op, strings.Join(activeHosts, ", "))
req.Filter.Expression = mergeFilterExpressions(req.Filter.Expression, statusClause)
return false
}
func (m *module) getHostsTableMetadata(ctx context.Context, req *inframonitoringtypes.HostsListRequest) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range hostAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
metadataMap, err := m.getMetadata(ctx, hostsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
if err != nil {
return nil, err
}
return metadataMap, nil
}
// buildHostRecords constructs the final list of HostRecords for a page.
// Groups that had no metric data get default values of -1.
func (m *module) buildHostRecords(
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
activeHostsMap map[string]bool,
) []inframonitoringtypes.HostRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.HostRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
hostName := labels[hostNameAttrKey]
activeStatus := inframonitoringtypes.HostStatusNone
if hostName != "" {
if activeHostsMap[hostName] {
activeStatus = inframonitoringtypes.HostStatusActive
} else {
activeStatus = inframonitoringtypes.HostStatusInactive
}
}
record := inframonitoringtypes.HostRecord{
HostName: hostName,
Status: activeStatus,
CPU: -1,
Memory: -1,
Wait: -1,
Load15: -1,
DiskUsage: -1,
Meta: map[string]interface{}{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["F1"]; exists {
record.CPU = v
}
if v, exists := metrics["F2"]; exists {
record.Memory = v
}
if v, exists := metrics["F3"]; exists {
record.Wait = v
}
if v, exists := metrics["F4"]; exists {
record.DiskUsage = v
}
if v, exists := metrics["G"]; exists {
record.Load15 = v
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
// getActiveHosts returns a set of host names that have reported metrics recently (since sinceUnixMilli).
// It queries distributed_metadata for hosts where last_reported_unix_milli >= sinceUnixMilli.
// TODO(nikhilmantri0902): This method does not return active hosts numbers based on custom grouping by. So
// if we have a different group by key than host.name in the API, then this method's response, will be useless technically, because
// with a group-by different from host.name, we should show count of active-inactive hosts in that group.
// We should have a way to determine active groups based on the group by keys in the request.
func (m *module) getActiveHosts(ctx context.Context, metricNames []string, hostNameAttr string) (map[string]bool, error) {
sinceUnixMilli := time.Now().Add(-10 * time.Minute).UTC().UnixMilli()
sb := sqlbuilder.NewSelectBuilder()
sb.Distinct()
sb.Select("attr_string_value")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.E("attr_name", hostNameAttr),
sb.GE("last_reported_unix_milli", sinceUnixMilli),
)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
activeHosts := make(map[string]bool)
for rows.Next() {
var hostName string
if err := rows.Scan(&hostName); err != nil {
return nil, err
}
if hostName != "" {
activeHosts[hostName] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return activeHosts, nil
}

View File

@@ -0,0 +1,143 @@
package implinframonitoring
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
telemetryStore telemetrystore.TelemetryStore
telemetryMetadataStore telemetrytypes.MetadataStore
querier querier.Querier
fieldMapper qbtypes.FieldMapper
condBuilder qbtypes.ConditionBuilder
logger *slog.Logger
config inframonitoring.Config
}
// NewModule constructs the inframonitoring module with the provided dependencies.
func NewModule(
telemetryStore telemetrystore.TelemetryStore,
telemetryMetadataStore telemetrytypes.MetadataStore,
querier querier.Querier,
providerSettings factory.ProviderSettings,
cfg inframonitoring.Config,
) inframonitoring.Module {
fieldMapper := telemetrymetrics.NewFieldMapper()
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
return &module{
telemetryStore: telemetryStore,
telemetryMetadataStore: telemetryMetadataStore,
querier: querier,
fieldMapper: fieldMapper,
condBuilder: condBuilder,
logger: providerSettings.Logger,
config: cfg,
}
}
func (m *module) HostsList(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.HostsListRequest) (*inframonitoringtypes.HostsListResponse, error) {
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.HostsListResponse{}
// default to cpu order by
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "cpu",
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
// default to host name group by
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{hostNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
// 1. Check which required metrics exist and get earliest retention time.
// If any required metric is missing, return early with the list of missing metrics.
// 2. If metrics exist but req.End is before the earliest reported time, convey retention boundary.
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, hostsTableMetricNamesList)
if err != nil {
return nil, err
}
if len(missingMetrics) > 0 {
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
resp.Records = []inframonitoringtypes.HostRecord{}
resp.Total = 0
return resp, nil
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.HostRecord{}
resp.Total = 0
return resp, nil
}
// Determine active hosts: those with metrics reported in the last 10 minutes.
activeHostsMap, err := m.getActiveHosts(ctx, hostsTableMetricNamesList, hostNameAttrKey)
if err != nil {
return nil, err
}
// this check below modifies req.Filter by adding `AND active hosts filter` if req.FilterByStatus is set.
if m.applyHostsActiveStatusFilter(req, activeHostsMap) {
resp.Records = []inframonitoringtypes.HostRecord{}
resp.Total = 0
return resp, nil
}
metadataMap, err := m.getHostsTableMetadata(ctx, req)
if err != nil {
return nil, err
}
if metadataMap == nil {
metadataMap = make(map[string]map[string]string)
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopHostGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.HostRecord{}
return resp, nil
}
hostsFilterExpr := ""
if req.Filter != nil {
hostsFilterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, hostsFilterExpr, req.GroupBy, pageGroups, m.newHostsTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
resp.Records = m.buildHostRecords(queryResp, pageGroups, req.GroupBy, metadataMap, activeHostsMap)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -0,0 +1,17 @@
package inframonitoring
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Handler interface {
HostsList(http.ResponseWriter, *http.Request)
}
type Module interface {
HostsList(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.HostsListRequest) (*inframonitoringtypes.HostsListResponse, error)
}

View File

@@ -0,0 +1,19 @@
package tracedetail
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, req *tracedetailtypes.WaterfallRequest) (*tracedetailtypes.WaterfallResponse, error)
}

View File

@@ -40,6 +40,7 @@ type querier struct {
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
@@ -56,6 +57,7 @@ func New(
promEngine prometheus.Prometheus,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
@@ -69,6 +71,7 @@ func New(
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
@@ -361,7 +364,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, q.logStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
stmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -550,7 +557,11 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
case <-tick:
// timestamp end is not specified here
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: tsStart}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, q.logStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
liveTailStmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
liveTailStmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
"id": {
Value: updatedLogID,
},
@@ -850,7 +861,11 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
return newBuilderQuery(q.logger, q.telemetryStore, q.logStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
shiftStmtBuilder := q.logStmtBuilder
if qt.spec.Source == telemetrytypes.SourceAudit {
shiftStmtBuilder = q.auditStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
case *builderQuery[qbtypes.MetricAggregation]:
specCopy := qt.spec.Copy()

View File

@@ -47,6 +47,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
@@ -110,6 +111,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
&mockMetricStmtBuilder{}, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryaudit"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
@@ -63,6 +64,11 @@ func newProvider(
telemetrylogs.TagAttributesV2TableName,
telemetrylogs.LogAttributeKeysTblName,
telemetrylogs.LogResourceKeysTblName,
telemetryaudit.DBName,
telemetryaudit.AuditLogsTableName,
telemetryaudit.TagAttributesTableName,
telemetryaudit.LogAttributeKeysTblName,
telemetryaudit.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
@@ -82,13 +88,13 @@ func newProvider(
telemetryStore,
)
// ADD: Create trace operator statement builder
// Create trace operator statement builder
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
settings,
telemetryMetadataStore,
traceFieldMapper,
traceConditionBuilder,
traceStmtBuilder, // Pass the regular trace statement builder
traceStmtBuilder,
traceAggExprRewriter,
)
@@ -112,6 +118,26 @@ func newProvider(
telemetrylogs.GetBodyJSONKey,
)
// Create audit statement builder
auditFieldMapper := telemetryaudit.NewFieldMapper()
auditConditionBuilder := telemetryaudit.NewConditionBuilder(auditFieldMapper)
auditAggExprRewriter := querybuilder.NewAggExprRewriter(
settings,
telemetryaudit.DefaultFullTextColumn,
auditFieldMapper,
auditConditionBuilder,
nil,
)
auditStmtBuilder := telemetryaudit.NewAuditQueryStatementBuilder(
settings,
telemetryMetadataStore,
auditFieldMapper,
auditConditionBuilder,
auditAggExprRewriter,
telemetryaudit.DefaultFullTextColumn,
nil,
)
// Create metric statement builder
metricFieldMapper := telemetrymetrics.NewFieldMapper()
metricConditionBuilder := telemetrymetrics.NewConditionBuilder(metricFieldMapper)
@@ -148,6 +174,7 @@ func newProvider(
prometheus,
traceStmtBuilder,
logStmtBuilder,
auditStmtBuilder,
metricStmtBuilder,
meterStmtBuilder,
traceOperatorStmtBuilder,

View File

@@ -208,7 +208,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, nil).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)

View File

@@ -46,6 +46,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
metricStmtBuilder,
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
@@ -91,6 +92,7 @@ func prepareQuerierForLogs(telemetryStore telemetrystore.TelemetryStore, keysMap
nil, // prometheus
nil, // traceStmtBuilder
logStmtBuilder, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
@@ -131,6 +133,7 @@ func prepareQuerierForTraces(telemetryStore telemetrystore.TelemetryStore, keysM
nil, // prometheus
traceStmtBuilder, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder

View File

@@ -818,9 +818,9 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
case "has":
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAny":
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value))
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAll":
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value))
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
}
conds = append(conds, cond)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/emailing"
@@ -21,6 +22,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -112,6 +114,9 @@ type Config struct {
// MetricsExplorer config
MetricsExplorer metricsexplorer.Config `mapstructure:"metricsexplorer"`
// InfraMonitoring config
InfraMonitoring inframonitoring.Config `mapstructure:"inframonitoring"`
// Flagger config
Flagger flagger.Config `mapstructure:"flagger"`
@@ -123,6 +128,9 @@ type Config struct {
// ServiceAccount config
ServiceAccount serviceaccount.Config `mapstructure:"serviceaccount"`
// Auditor config
Auditor auditor.Config `mapstructure:"auditor"`
}
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
@@ -149,10 +157,12 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
gateway.NewConfigFactory(),
tokenizer.NewConfigFactory(),
metricsexplorer.NewConfigFactory(),
inframonitoring.NewConfigFactory(),
flagger.NewConfigFactory(),
user.NewConfigFactory(),
identn.NewConfigFactory(),
serviceaccount.NewConfigFactory(),
auditor.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)

View File

@@ -18,6 +18,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/fields/implfields"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
@@ -51,6 +53,7 @@ type Handlers struct {
SpanPercentile spanpercentile.Handler
Services services.Handler
MetricsExplorer metricsexplorer.Handler
InfraMonitoring inframonitoring.Handler
Global global.Handler
FlaggerHandler flagger.Handler
GatewayHandler gateway.Handler
@@ -87,6 +90,7 @@ func NewHandlers(
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
Services: implservices.NewHandler(modules.Services),
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
InfraMonitoring: implinframonitoring.NewHandler(modules.InfraMonitoring),
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
Global: signozglobal.NewHandler(global),
FlaggerHandler: flagger.NewHandler(flaggerService),

View File

@@ -13,6 +13,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -69,6 +71,7 @@ type Modules struct {
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
RuleStateHistory rulestatehistory.Module
@@ -116,6 +119,7 @@ func NewModules(
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),

View File

@@ -21,6 +21,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
@@ -59,6 +60,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ dashboard.Module }{},
struct{ dashboard.Handler }{},
struct{ metricsexplorer.Handler }{},
struct{ inframonitoring.Handler }{},
struct{ gateway.Handler }{},
struct{ fields.Handler }{},
struct{ authz.Handler }{},

View File

@@ -3,6 +3,8 @@ package signoz
import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/noopauditor"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
@@ -276,6 +278,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
modules.Dashboard,
handlers.Dashboard,
handlers.MetricsExplorer,
handlers.InfraMonitoring,
handlers.GatewayHandler,
handlers.Fields,
handlers.AuthzHandler,
@@ -312,6 +315,12 @@ func NewGlobalProviderFactories(identNConfig identn.Config) factory.NamedMap[fac
)
}
func NewAuditorProviderFactories() factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return factory.MustNewNamedMap(
noopauditor.NewFactory(),
)
}
func NewFlaggerProviderFactories(registry featuretypes.Registry) factory.NamedMap[factory.ProviderFactory[flagger.FlaggerProvider, flagger.Config]] {
return factory.MustNewNamedMap(
configflagger.NewFactory(registry),

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/apiserver"
@@ -33,6 +34,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/telemetryaudit"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
@@ -74,6 +76,7 @@ type SigNoz struct {
QueryParser queryparser.QueryParser
Flagger flagger.Flagger
Gateway gateway.Gateway
Auditor auditor.Auditor
}
func New(
@@ -93,6 +96,7 @@ func New(
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
) (*SigNoz, error) {
// Initialize instrumentation
@@ -370,6 +374,12 @@ func New(
return nil, err
}
// Initialize auditor from the variant-specific provider factories
auditor, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Auditor, auditorProviderFactories(licensing), config.Auditor.Provider)
if err != nil {
return nil, err
}
// Initialize authns
store := sqlauthnstore.NewStore(sqlstore)
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
@@ -395,6 +405,11 @@ func New(
telemetrylogs.TagAttributesV2TableName,
telemetrylogs.LogAttributeKeysTblName,
telemetrylogs.LogResourceKeysTblName,
telemetryaudit.DBName,
telemetryaudit.AuditLogsTableName,
telemetryaudit.TagAttributesTableName,
telemetryaudit.LogAttributeKeysTblName,
telemetryaudit.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
@@ -464,6 +479,7 @@ func New(
factory.NewNamedService(factory.MustNewName("tokenizer"), tokenizer),
factory.NewNamedService(factory.MustNewName("authz"), authz),
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
)
if err != nil {
return nil, err
@@ -510,5 +526,6 @@ func New(
QueryParser: queryParser,
Flagger: flagger,
Gateway: gateway,
Auditor: auditor,
}, nil
}

View File

@@ -0,0 +1,200 @@
package telemetryaudit
import (
"context"
"fmt"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
type conditionBuilder struct {
fm qbtypes.FieldMapper
}
func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
return &conditionBuilder{fm: fm}
}
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
columns, err := c.fm.ColumnFor(ctx, startNs, endNs, key)
if err != nil {
return "", err
}
if operator.IsStringSearchOperator() {
value = querybuilder.FormatValueForContains(value)
}
fieldExpression, err := c.fm.FieldFor(ctx, startNs, endNs, key)
if err != nil {
return "", err
}
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
switch operator {
case qbtypes.FilterOperatorEqual:
return sb.E(fieldExpression, value), nil
case qbtypes.FilterOperatorNotEqual:
return sb.NE(fieldExpression, value), nil
case qbtypes.FilterOperatorGreaterThan:
return sb.G(fieldExpression, value), nil
case qbtypes.FilterOperatorGreaterThanOrEq:
return sb.GE(fieldExpression, value), nil
case qbtypes.FilterOperatorLessThan:
return sb.LT(fieldExpression, value), nil
case qbtypes.FilterOperatorLessThanOrEq:
return sb.LE(fieldExpression, value), nil
case qbtypes.FilterOperatorLike:
return sb.Like(fieldExpression, value), nil
case qbtypes.FilterOperatorNotLike:
return sb.NotLike(fieldExpression, value), nil
case qbtypes.FilterOperatorILike:
return sb.ILike(fieldExpression, value), nil
case qbtypes.FilterOperatorNotILike:
return sb.NotILike(fieldExpression, value), nil
case qbtypes.FilterOperatorContains:
return sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorNotContains:
return sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorRegexp:
return fmt.Sprintf(`match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
case qbtypes.FilterOperatorNotRegexp:
return fmt.Sprintf(`NOT match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
case qbtypes.FilterOperatorBetween:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrBetweenValues
}
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
return sb.Between(fieldExpression, values[0], values[1]), nil
case qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrBetweenValues
}
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
return sb.NotBetween(fieldExpression, values[0], values[1]), nil
case qbtypes.FilterOperatorIn:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrInValues
}
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrInValues
}
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
var value any
column := columns[0]
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(fieldExpression), nil
}
return sb.IsNull(fieldExpression), nil
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(fieldExpression, value), nil
}
return sb.E(fieldExpression, value), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(fieldExpression, value), nil
}
return sb.E(fieldExpression, value), nil
case schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
value = 0
if operator == qbtypes.FilterOperatorExists {
return sb.NE(fieldExpression, value), nil
}
return sb.E(fieldExpression, value), nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
if key.Materialized {
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
}
if operator == qbtypes.FilterOperatorExists {
return sb.E(leftOperand, true), nil
}
return sb.NE(leftOperand, true), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
}
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
condition, err := c.conditionFor(ctx, startNs, endNs, key, operator, value, sb)
if err != nil {
return "", err
}
if key.FieldContext == telemetrytypes.FieldContextLog || key.FieldContext == telemetrytypes.FieldContextScope {
return condition, nil
}
if operator.AddDefaultExistsFilter() {
existsCondition, err := c.conditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", err
}
return sb.And(condition, existsCondition), nil
}
return condition, nil
}

129
pkg/telemetryaudit/const.go Normal file
View File

@@ -0,0 +1,129 @@
package telemetryaudit
import (
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// Internal Columns.
IDColumn = "id"
TimestampBucketStartColumn = "ts_bucket_start"
ResourceFingerPrintColumn = "resource_fingerprint"
// Intrinsic Columns.
TimestampColumn = "timestamp"
ObservedTimestampColumn = "observed_timestamp"
BodyColumn = "body"
EventNameColumn = "event_name"
TraceIDColumn = "trace_id"
SpanIDColumn = "span_id"
TraceFlagsColumn = "trace_flags"
SeverityTextColumn = "severity_text"
SeverityNumberColumn = "severity_number"
ScopeNameColumn = "scope_name"
ScopeVersionColumn = "scope_version"
// Contextual Columns.
AttributesStringColumn = "attributes_string"
AttributesNumberColumn = "attributes_number"
AttributesBoolColumn = "attributes_bool"
ResourceColumn = "resource"
ScopeStringColumn = "scope_string"
)
var (
DefaultFullTextColumn = &telemetrytypes.TelemetryFieldKey{
Name: "body",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
"body": {
Name: "body",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"trace_id": {
Name: "trace_id",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"span_id": {
Name: "span_id",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"trace_flags": {
Name: "trace_flags",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
"severity_text": {
Name: "severity_text",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"severity_number": {
Name: "severity_number",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
"event_name": {
Name: "event_name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
DefaultSortingOrder = []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: TimestampColumn,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: IDColumn,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
}
)
var auditLogColumns = map[string]*schema.Column{
"ts_bucket_start": {Name: "ts_bucket_start", Type: schema.ColumnTypeUInt64},
"resource_fingerprint": {Name: "resource_fingerprint", Type: schema.ColumnTypeString},
"timestamp": {Name: "timestamp", Type: schema.ColumnTypeUInt64},
"observed_timestamp": {Name: "observed_timestamp", Type: schema.ColumnTypeUInt64},
"id": {Name: "id", Type: schema.ColumnTypeString},
"trace_id": {Name: "trace_id", Type: schema.ColumnTypeString},
"span_id": {Name: "span_id", Type: schema.ColumnTypeString},
"trace_flags": {Name: "trace_flags", Type: schema.ColumnTypeUInt32},
"severity_text": {Name: "severity_text", Type: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}},
"severity_number": {Name: "severity_number", Type: schema.ColumnTypeUInt8},
"body": {Name: "body", Type: schema.ColumnTypeString},
"attributes_string": {Name: "attributes_string", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeString}},
"attributes_number": {Name: "attributes_number", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeFloat64}},
"attributes_bool": {Name: "attributes_bool", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeBool}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"event_name": {Name: "event_name", Type: schema.ColumnTypeString},
"scope_name": {Name: "scope_name", Type: schema.ColumnTypeString},
"scope_version": {Name: "scope_version", Type: schema.ColumnTypeString},
"scope_string": {Name: "scope_string", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeString}},
}

View File

@@ -0,0 +1,124 @@
package telemetryaudit
import (
"context"
"fmt"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/exp/maps"
)
type fieldMapper struct{}
func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
return []*schema.Column{auditLogColumns["resource"]}, nil
case telemetrytypes.FieldContextScope:
switch key.Name {
case "name", "scope.name", "scope_name":
return []*schema.Column{auditLogColumns["scope_name"]}, nil
case "version", "scope.version", "scope_version":
return []*schema.Column{auditLogColumns["scope_version"]}, nil
}
return []*schema.Column{auditLogColumns["scope_string"]}, nil
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
return []*schema.Column{auditLogColumns["attributes_string"]}, nil
case telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber:
return []*schema.Column{auditLogColumns["attributes_number"]}, nil
case telemetrytypes.FieldDataTypeBool:
return []*schema.Column{auditLogColumns["attributes_bool"]}, nil
}
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
col, ok := auditLogColumns[key.Name]
if !ok {
return nil, qbtypes.ErrColumnNotFound
}
return []*schema.Column{col}, nil
}
return nil, qbtypes.ErrColumnNotFound
}
func (m *fieldMapper) FieldFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
if len(columns) != 1 {
return "", errors.Newf(errors.TypeInternal, errors.CodeInternal, "expected exactly 1 column, got %d", len(columns))
}
column := columns[0]
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if key.FieldContext != telemetrytypes.FieldContextResource {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns in audit, got %s", key.FieldContext.String)
}
return fmt.Sprintf("%s.`%s`::String", column.Name, key.Name), nil
case schema.ColumnTypeEnumLowCardinality:
return column.Name, nil
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
return column.Name, nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported map value type %s", valueType)
}
}
return column.Name, nil
}
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
return m.getColumn(ctx, key)
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
tsStart, tsEnd uint64,
field *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
fieldExpression, err := m.FieldFor(ctx, tsStart, tsEnd, field)
if errors.Is(err, qbtypes.ErrColumnNotFound) {
keysForField := keys[field.Name]
if len(keysForField) == 0 {
if _, ok := auditLogColumns[field.Name]; ok {
field.FieldContext = telemetrytypes.FieldContextLog
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
} else {
correction, found := telemetrytypes.SuggestCorrection(field.Name, maps.Keys(keys))
if found {
return "", errors.Wrap(err, errors.TypeInvalidInput, errors.CodeInvalidInput, correction)
}
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name)
}
} else {
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, keysForField[0])
}
}
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
}

View File

@@ -0,0 +1,612 @@
package telemetryaudit
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryresourcefilter"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
type auditQueryStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
}
var _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*auditQueryStatementBuilder)(nil)
func NewAuditQueryStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
fieldMapper qbtypes.FieldMapper,
conditionBuilder qbtypes.ConditionBuilder,
aggExprRewriter qbtypes.AggExprRewriter,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
) *auditQueryStatementBuilder {
auditSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryaudit")
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.LogAggregation](
settings,
DBName,
LogsResourceTableName,
telemetrytypes.SignalLogs,
telemetrytypes.SourceAudit,
metadataStore,
fullTextColumn,
jsonKeyToKey,
)
return &auditQueryStatementBuilder{
logger: auditSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
aggExprRewriter: aggExprRewriter,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
}
}
func (b *auditQueryStatementBuilder) Build(
ctx context.Context,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
start = querybuilder.ToNanoSecs(start)
end = querybuilder.ToNanoSecs(end)
keySelectors := getKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
query = b.adjustKeys(ctx, keys, query, requestType)
q := sqlbuilder.NewSelectBuilder()
var stmt *qbtypes.Statement
switch requestType {
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeRawStream:
stmt, err = b.buildListQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeTimeSeries:
stmt, err = b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
stmt, err = b.buildScalarQuery(ctx, q, query, start, end, keys, false, variables)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
}
if err != nil {
return nil, err
}
return stmt, nil
}
func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) []*telemetrytypes.FieldKeySelector {
var keySelectors []*telemetrytypes.FieldKeySelector
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression)
keySelectors = append(keySelectors, selectors...)
}
if query.Filter != nil && query.Filter.Expression != "" {
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression)
keySelectors = append(keySelectors, whereClauseSelectors...)
}
for idx := range query.GroupBy {
groupBy := query.GroupBy[idx]
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: groupBy.Name,
Signal: telemetrytypes.SignalLogs,
FieldContext: groupBy.FieldContext,
FieldDataType: groupBy.FieldDataType,
})
}
for idx := range query.SelectFields {
selectField := query.SelectFields[idx]
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: selectField.Name,
Signal: telemetrytypes.SignalLogs,
FieldContext: selectField.FieldContext,
FieldDataType: selectField.FieldDataType,
})
}
for idx := range query.Order {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: query.Order[idx].Key.Name,
Signal: telemetrytypes.SignalLogs,
FieldContext: query.Order[idx].Key.FieldContext,
FieldDataType: query.Order[idx].Key.FieldDataType,
})
}
for idx := range keySelectors {
keySelectors[idx].Signal = telemetrytypes.SignalLogs
keySelectors[idx].Source = telemetrytypes.SourceAudit
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
}
return keySelectors
}
func (b *auditQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.LogAggregation] {
keys["id"] = append([]*telemetrytypes.TelemetryFieldKey{{
Name: "id",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}, keys["id"]...)
keys["timestamp"] = append([]*telemetrytypes.TelemetryFieldKey{{
Name: "timestamp",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}}, keys["timestamp"]...)
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
for idx := range query.SelectFields {
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
}
for idx := range query.GroupBy {
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
}
for idx := range query.Order {
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
}
for _, action := range actions {
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
}
return query
}
func (b *auditQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
if _, ok := IntrinsicFields[key.Name]; ok {
intrinsicField := IntrinsicFields[key.Name]
return querybuilder.AdjustKey(key, keys, &intrinsicField)
}
return querybuilder.AdjustKey(key, keys, nil)
}
func (b *auditQueryStatementBuilder) buildListQuery(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
return nil, err
} else if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
sb.Select(TimestampColumn)
sb.SelectMore(IDColumn)
if len(query.SelectFields) == 0 {
sb.SelectMore(TraceIDColumn)
sb.SelectMore(SpanIDColumn)
sb.SelectMore(TraceFlagsColumn)
sb.SelectMore(SeverityTextColumn)
sb.SelectMore(SeverityNumberColumn)
sb.SelectMore(ScopeNameColumn)
sb.SelectMore(ScopeVersionColumn)
sb.SelectMore(BodyColumn)
sb.SelectMore(EventNameColumn)
sb.SelectMore(AttributesStringColumn)
sb.SelectMore(AttributesNumberColumn)
sb.SelectMore(AttributesBoolColumn)
sb.SelectMore(ResourceColumn)
sb.SelectMore(ScopeStringColumn)
} else {
for index := range query.SelectFields {
if query.SelectFields[index].Name == TimestampColumn || query.SelectFields[index].Name == IDColumn {
continue
}
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &query.SelectFields[index], keys)
if err != nil {
return nil, err
}
sb.SelectMore(colExpr)
}
}
sb.From(fmt.Sprintf("%s.%s", DBName, AuditLogsTableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
if err != nil {
return nil, err
}
for _, orderBy := range query.Order {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &orderBy.Key.TelemetryFieldKey, keys)
if err != nil {
return nil, err
}
sb.OrderBy(fmt.Sprintf("%s %s", colExpr, orderBy.Direction.StringValue()))
}
if query.Limit > 0 {
sb.Limit(query.Limit)
} else {
sb.Limit(100)
}
if query.Offset > 0 {
sb.Offset(query.Offset)
}
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
}
if preparedWhereClause != nil {
stmt.Warnings = preparedWhereClause.Warnings
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
}
return stmt, nil
}
func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
return nil, err
} else if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
sb.SelectMore(fmt.Sprintf(
"toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL %d SECOND) AS ts",
int64(query.StepInterval.Seconds()),
))
var allGroupByArgs []any
fieldNames := make([]string, 0, len(query.GroupBy))
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
if err != nil {
return nil, err
}
colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.Name)
allGroupByArgs = append(allGroupByArgs, args...)
sb.SelectMore(colExpr)
fieldNames = append(fieldNames, fmt.Sprintf("`%s`", gb.Name))
}
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
if err != nil {
return nil, err
}
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
sb.From(fmt.Sprintf("%s.%s", DBName, AuditLogsTableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
if err != nil {
return nil, err
}
var finalSQL string
var finalArgs []any
if query.Limit > 0 && len(query.GroupBy) > 0 {
cteSB := sqlbuilder.NewSelectBuilder()
cteStmt, err := b.buildScalarQuery(ctx, cteSB, query, start, end, keys, true, variables)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", cteStmt.Query))
cteArgs = append(cteArgs, cteStmt.Args)
tuple := fmt.Sprintf("(%s)", strings.Join(fieldNames, ", "))
sb.Where(fmt.Sprintf("%s GLOBAL IN (SELECT %s FROM __limit_cte)", tuple, strings.Join(fieldNames, ", ")))
sb.GroupBy("ts")
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
if len(query.Order) != 0 {
for _, orderBy := range query.Order {
_, ok := aggOrderBy(orderBy, query)
if !ok {
sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
}
}
sb.OrderBy("ts desc")
}
combinedArgs := append(allGroupByArgs, allAggChArgs...)
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
} else {
sb.GroupBy("ts")
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
if len(query.Order) != 0 {
for _, orderBy := range query.Order {
_, ok := aggOrderBy(orderBy, query)
if !ok {
sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
}
}
sb.OrderBy("ts desc")
}
combinedArgs := append(allGroupByArgs, allAggChArgs...)
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
}
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
}
if preparedWhereClause != nil {
stmt.Warnings = preparedWhereClause.Warnings
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
}
return stmt, nil
}
func (b *auditQueryStatementBuilder) buildScalarQuery(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
skipResourceCTE bool,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
return nil, err
} else if frag != "" && !skipResourceCTE {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
allAggChArgs := []any{}
var allGroupByArgs []any
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
if err != nil {
return nil, err
}
colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.Name)
allGroupByArgs = append(allGroupByArgs, args...)
sb.SelectMore(colExpr)
}
rateInterval := (end - start) / querybuilder.NsToSeconds
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, start, end, aggExpr.Expression, rateInterval, keys)
if err != nil {
return nil, err
}
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
}
sb.From(fmt.Sprintf("%s.%s", DBName, AuditLogsTableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
if err != nil {
return nil, err
}
sb.GroupBy(querybuilder.GroupByKeys(query.GroupBy)...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(rewrittenExpr)
}
for _, orderBy := range query.Order {
idx, ok := aggOrderBy(orderBy, query)
if ok {
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
} else {
sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue()))
}
}
if len(query.Order) == 0 {
sb.OrderBy("__result_0 DESC")
}
if query.Limit > 0 {
sb.Limit(query.Limit)
}
combinedArgs := append(allGroupByArgs, allAggChArgs...)
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
}
if preparedWhereClause != nil {
stmt.Warnings = preparedWhereClause.Warnings
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
}
return stmt, nil
}
func (b *auditQueryStatementBuilder) addFilterCondition(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
) (*querybuilder.PreparedWhereClause, error) {
var preparedWhereClause *querybuilder.PreparedWhereClause
var err error
if query.Filter != nil && query.Filter.Expression != "" {
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
SkipResourceFilter: true,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,
EndNs: end,
})
if err != nil {
return nil, err
}
}
if preparedWhereClause != nil {
sb.AddWhereClause(preparedWhereClause.WhereClause)
}
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
var endBucket uint64
if end != 0 {
endBucket = end / querybuilder.NsToSeconds
}
if start != 0 {
sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.GE("ts_bucket_start", startBucket))
}
if end != 0 {
sb.Where(sb.L("timestamp", fmt.Sprintf("%d", end)), sb.LE("ts_bucket_start", endBucket))
}
return preparedWhereClause, nil
}
func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) (int, bool) {
for i, agg := range q.Aggregations {
if k.Key.Name == agg.Alias || k.Key.Name == agg.Expression || k.Key.Name == fmt.Sprintf("%d", i) {
return i, true
}
}
return 0, false
}
func (b *auditQueryStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (cteSQL string, cteArgs []any, err error) {
stmt, err := b.resourceFilterStmtBuilder.Build(ctx, start, end, qbtypes.RequestTypeRaw, query, variables)
if err != nil {
return "", nil, err
}
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
}

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