mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-25 13:20:24 +01:00
Compare commits
23 Commits
ee-auditor
...
issue_4361
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc63dc0b28 | ||
|
|
fbb47f7fb9 | ||
|
|
7aa69a3265 | ||
|
|
6de9ebfcac | ||
|
|
85c7ce0680 | ||
|
|
910cbf55cf | ||
|
|
c30b3870f2 | ||
|
|
a4da71eb52 | ||
|
|
f5ed4c90b0 | ||
|
|
c03e97221a | ||
|
|
cd224616f0 | ||
|
|
50b42aca8c | ||
|
|
dbad6cff96 | ||
|
|
73a44cee77 | ||
|
|
b9b7c10af9 | ||
|
|
3d71707f0b | ||
|
|
f59f611ea2 | ||
|
|
07131f10a9 | ||
|
|
d930e77870 | ||
|
|
8a9f6bee98 | ||
|
|
4b06b4546f | ||
|
|
526b08a179 | ||
|
|
c7ab610bd8 |
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
|
||||
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
@@ -156,9 +155,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
if err := factories.Add(otlphttpauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return factories
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
|
||||
@@ -11,8 +11,6 @@ global:
|
||||
external_url: <unset>
|
||||
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
|
||||
ingestion_url: <unset>
|
||||
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
|
||||
# mcp_url: <unset>
|
||||
|
||||
##################### Version #####################
|
||||
version:
|
||||
|
||||
@@ -2369,13 +2369,6 @@ components:
|
||||
$ref: '#/components/schemas/GlobaltypesIdentNConfig'
|
||||
ingestion_url:
|
||||
type: string
|
||||
mcp_url:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- external_url
|
||||
- ingestion_url
|
||||
- mcp_url
|
||||
type: object
|
||||
GlobaltypesIdentNConfig:
|
||||
properties:
|
||||
@@ -4496,6 +4489,184 @@ components:
|
||||
type: object
|
||||
Sigv4SigV4Config:
|
||||
type: object
|
||||
SpantypesFieldContext:
|
||||
enum:
|
||||
- attribute
|
||||
- resource
|
||||
type: string
|
||||
SpantypesGettableSpanMapperGroups:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroup'
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperConfig'
|
||||
enabled:
|
||||
type: boolean
|
||||
field_context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- field_context
|
||||
- config
|
||||
type: object
|
||||
SpantypesPostableSpanMapperGroup:
|
||||
properties:
|
||||
category:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCategory'
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- category
|
||||
- condition
|
||||
type: object
|
||||
SpantypesSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
field_context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
group_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- group_id
|
||||
- name
|
||||
- field_context
|
||||
- config
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperConfig:
|
||||
properties:
|
||||
sources:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperSource'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- sources
|
||||
type: object
|
||||
SpantypesSpanMapperGroup:
|
||||
properties:
|
||||
category:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCategory'
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- name
|
||||
- category
|
||||
- condition
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperGroupCategory:
|
||||
type: object
|
||||
SpantypesSpanMapperGroupCondition:
|
||||
properties:
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
resource:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesSpanMapperOperation:
|
||||
enum:
|
||||
- move
|
||||
- copy
|
||||
type: string
|
||||
SpantypesSpanMapperSource:
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
key:
|
||||
type: string
|
||||
operation:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
|
||||
priority:
|
||||
type: integer
|
||||
required:
|
||||
- key
|
||||
- context
|
||||
- operation
|
||||
- priority
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperConfig'
|
||||
enabled:
|
||||
nullable: true
|
||||
type: boolean
|
||||
field_context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapperGroup:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
|
||||
enabled:
|
||||
nullable: true
|
||||
type: boolean
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -9238,6 +9409,487 @@ paths:
|
||||
summary: Updates my service account
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/span_mapper_groups:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns all span attribute mapping groups for the authenticated
|
||||
org.
|
||||
operationId: ListSpanMapperGroups
|
||||
parameters:
|
||||
- explode: true
|
||||
in: query
|
||||
name: category
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCategory'
|
||||
style: deepObject
|
||||
- in: query
|
||||
name: enabled
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
|
||||
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 span attribute mapping groups
|
||||
tags:
|
||||
- spanmapper
|
||||
post:
|
||||
deprecated: false
|
||||
description: Creates a new span attribute mapping group for the org.
|
||||
operationId: CreateSpanMapperGroup
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperGroup'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroup'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create a span attribute mapping group
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/span_mapper_groups/{groupId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Hard-deletes a mapping group and cascades to all its mappers.
|
||||
operationId: DeleteSpanMapperGroup
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete a span attribute mapping group
|
||||
tags:
|
||||
- spanmapper
|
||||
patch:
|
||||
deprecated: false
|
||||
description: Partially updates an existing mapping group's name, condition,
|
||||
or enabled state.
|
||||
operationId: UpdateSpanMapperGroup
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesUpdatableSpanMapperGroup'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update a span attribute mapping group
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/span_mapper_groups/{groupId}/span_mappers:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns all mappers belonging to a mapping group.
|
||||
operationId: ListSpanMappers
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List span mappers for a group
|
||||
tags:
|
||||
- spanmapper
|
||||
post:
|
||||
deprecated: false
|
||||
description: Adds a new mapper to the specified mapping group.
|
||||
operationId: CreateSpanMapper
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapper'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Hard-deletes a mapper from a mapping group.
|
||||
operationId: DeleteSpanMapper
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: mapperId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
patch:
|
||||
deprecated: false
|
||||
description: Partially updates an existing mapper's field context, config, or
|
||||
enabled state.
|
||||
operationId: UpdateSpanMapper
|
||||
parameters:
|
||||
- in: path
|
||||
name: groupId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: mapperId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesUpdatableSpanMapper'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Update a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/testChannel:
|
||||
post:
|
||||
deprecated: true
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package fileauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
)
|
||||
|
||||
// export converts a batch of audit events to OTLP-JSON log records and appends
|
||||
// the encoded payload to the configured file as one NDJSON line per batch.
|
||||
// Mirrors the wire format that otlphttpauditor sends, so downstream tools can
|
||||
// consume both transports interchangeably.
|
||||
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
|
||||
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
|
||||
|
||||
payload, err := provider.marshaler.MarshalLogs(logs)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
|
||||
if _, err := provider.file.Write(payload); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
|
||||
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to write audit logs")
|
||||
}
|
||||
|
||||
if _, err := provider.file.Write([]byte("\n")); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to write audit log newline")
|
||||
}
|
||||
|
||||
return provider.file.Sync()
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package fileauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
)
|
||||
|
||||
var _ auditor.Auditor = (*provider)(nil)
|
||||
|
||||
type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
config auditor.Config
|
||||
licensing licensing.Licensing
|
||||
build version.Build
|
||||
server *auditorserver.Server
|
||||
marshaler plog.JSONMarshaler
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("file"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
|
||||
return newProvider(ctx, providerSettings, config, licensing, build)
|
||||
})
|
||||
}
|
||||
|
||||
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/fileauditor")
|
||||
|
||||
file, err := os.OpenFile(config.File.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, auditor.ErrCodeAuditExportFailed, "failed to open audit file %q", config.File.Path)
|
||||
}
|
||||
|
||||
provider := &provider{
|
||||
settings: settings,
|
||||
config: config,
|
||||
licensing: licensing,
|
||||
build: build,
|
||||
marshaler: plog.JSONMarshaler{},
|
||||
file: file,
|
||||
}
|
||||
|
||||
server, err := auditorserver.New(settings,
|
||||
auditorserver.Config{
|
||||
BufferSize: config.BufferSize,
|
||||
BatchSize: config.BatchSize,
|
||||
FlushInterval: config.FlushInterval,
|
||||
},
|
||||
provider.export,
|
||||
)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider.server = server
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
return provider.server.Start(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
|
||||
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
|
||||
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
provider.server.Add(ctx, event)
|
||||
}
|
||||
|
||||
func (provider *provider) Healthy() <-chan struct{} {
|
||||
return provider.server.Healthy()
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
serverErr := provider.server.Stop(ctx)
|
||||
fileErr := provider.file.Close()
|
||||
if serverErr != nil {
|
||||
return serverErr
|
||||
}
|
||||
return fileErr
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
@@ -111,9 +112,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
}
|
||||
|
||||
// initiate agent config handler
|
||||
spanAttrMappingFeature := implspanmapper.NewSpanAttrMappingFeature(signoz.Modules.SpanMapper)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
|
||||
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, spanAttrMappingFeature},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -614,9 +614,8 @@
|
||||
"signoz/no-navigator-clipboard": "off",
|
||||
// Tests can use navigator.clipboard directly,
|
||||
"signoz/no-raw-absolute-path":"off",
|
||||
"no-restricted-globals": "off",
|
||||
"no-restricted-globals": "off"
|
||||
// Tests need raw localStorage/sessionStorage to seed DOM state for isolation
|
||||
"signoz/no-zustand-getstate-in-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3125,17 +3125,12 @@ export interface GlobaltypesConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_url: string;
|
||||
external_url?: string;
|
||||
identN?: GlobaltypesIdentNConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
ingestion_url: string;
|
||||
/**
|
||||
* @type string
|
||||
* @nullable true
|
||||
*/
|
||||
mcp_url: string | null;
|
||||
ingestion_url?: string;
|
||||
}
|
||||
|
||||
export interface GlobaltypesIdentNConfigDTO {
|
||||
@@ -5475,6 +5470,187 @@ export interface Sigv4SigV4ConfigDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum SpantypesFieldContextDTO {
|
||||
attribute = 'attribute',
|
||||
resource = 'resource',
|
||||
}
|
||||
export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
field_context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
category: SpantypesSpanMapperGroupCategoryDTO;
|
||||
condition: SpantypesSpanMapperGroupConditionDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperDTO {
|
||||
config: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
field_context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
sources: SpantypesSpanMapperSourceDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperGroupDTO {
|
||||
category: SpantypesSpanMapperGroupCategoryDTO;
|
||||
condition: SpantypesSpanMapperGroupConditionDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperGroupCategoryDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanMapperGroupConditionDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
resource: string[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SpantypesUpdatableSpanMapperDTO {
|
||||
config?: SpantypesSpanMapperConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @nullable true
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
field_context?: SpantypesFieldContextDTO;
|
||||
}
|
||||
|
||||
export interface SpantypesUpdatableSpanMapperGroupDTO {
|
||||
condition?: SpantypesSpanMapperGroupConditionDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @nullable true
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
* @nullable true
|
||||
*/
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
@@ -6925,6 +7101,71 @@ export type GetMyServiceAccount200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListSpanMapperGroupsParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
category?: SpantypesSpanMapperGroupCategoryDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @nullable true
|
||||
* @description undefined
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
|
||||
export type ListSpanMapperGroups200 = {
|
||||
data: SpantypesGettableSpanMapperGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateSpanMapperGroup201 = {
|
||||
data: SpantypesSpanMapperGroupDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteSpanMapperGroupPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type UpdateSpanMapperGroupPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListSpanMappersPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type ListSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperGroupsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
};
|
||||
export type CreateSpanMapper201 = {
|
||||
data: SpantypesSpanMapperDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type ListUsersDeprecated200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
787
frontend/src/api/generated/services/spanmapper/index.ts
Normal file
787
frontend/src/api/generated/services/spanmapper/index.ts
Normal file
@@ -0,0 +1,787 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateSpanMapper201,
|
||||
CreateSpanMapperGroup201,
|
||||
CreateSpanMapperPathParameters,
|
||||
DeleteSpanMapperGroupPathParameters,
|
||||
DeleteSpanMapperPathParameters,
|
||||
ListSpanMapperGroups200,
|
||||
ListSpanMapperGroupsParams,
|
||||
ListSpanMappers200,
|
||||
ListSpanMappersPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns all span attribute mapping groups for the authenticated org.
|
||||
* @summary List span attribute mapping groups
|
||||
*/
|
||||
export const listSpanMapperGroups = (
|
||||
params?: ListSpanMapperGroupsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListSpanMapperGroups200>({
|
||||
url: `/api/v1/span_mapper_groups`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSpanMapperGroupsQueryKey = (
|
||||
params?: ListSpanMapperGroupsParams,
|
||||
) => {
|
||||
return [`/api/v1/span_mapper_groups`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListSpanMapperGroupsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSpanMapperGroups>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSpanMapperGroupsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanMapperGroups>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListSpanMapperGroupsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listSpanMapperGroups>>
|
||||
> = ({ signal }) => listSpanMapperGroups(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanMapperGroups>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSpanMapperGroupsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSpanMapperGroups>>
|
||||
>;
|
||||
export type ListSpanMapperGroupsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List span attribute mapping groups
|
||||
*/
|
||||
|
||||
export function useListSpanMapperGroups<
|
||||
TData = Awaited<ReturnType<typeof listSpanMapperGroups>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSpanMapperGroupsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanMapperGroups>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSpanMapperGroupsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List span attribute mapping groups
|
||||
*/
|
||||
export const invalidateListSpanMapperGroups = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListSpanMapperGroupsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListSpanMapperGroupsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new span attribute mapping group for the org.
|
||||
* @summary Create a span attribute mapping group
|
||||
*/
|
||||
export const createSpanMapperGroup = (
|
||||
spantypesPostableSpanMapperGroupDTO: BodyType<SpantypesPostableSpanMapperGroupDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateSpanMapperGroup201>({
|
||||
url: `/api/v1/span_mapper_groups`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperGroupDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateSpanMapperGroupMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSpanMapperGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpantypesPostableSpanMapperGroupDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSpanMapperGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpantypesPostableSpanMapperGroupDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createSpanMapperGroup'];
|
||||
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 createSpanMapperGroup>>,
|
||||
{ data: BodyType<SpantypesPostableSpanMapperGroupDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createSpanMapperGroup(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateSpanMapperGroupMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createSpanMapperGroup>>
|
||||
>;
|
||||
export type CreateSpanMapperGroupMutationBody =
|
||||
BodyType<SpantypesPostableSpanMapperGroupDTO>;
|
||||
export type CreateSpanMapperGroupMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a span attribute mapping group
|
||||
*/
|
||||
export const useCreateSpanMapperGroup = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSpanMapperGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpantypesPostableSpanMapperGroupDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSpanMapperGroup>>,
|
||||
TError,
|
||||
{ data: BodyType<SpantypesPostableSpanMapperGroupDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateSpanMapperGroupMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Hard-deletes a mapping group and cascades to all its mappers.
|
||||
* @summary Delete a span attribute mapping group
|
||||
*/
|
||||
export const deleteSpanMapperGroup = ({
|
||||
groupId,
|
||||
}: DeleteSpanMapperGroupPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_mapper_groups/${groupId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteSpanMapperGroupMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSpanMapperGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperGroupPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSpanMapperGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperGroupPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteSpanMapperGroup'];
|
||||
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 deleteSpanMapperGroup>>,
|
||||
{ pathParams: DeleteSpanMapperGroupPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteSpanMapperGroup(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteSpanMapperGroupMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteSpanMapperGroup>>
|
||||
>;
|
||||
|
||||
export type DeleteSpanMapperGroupMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a span attribute mapping group
|
||||
*/
|
||||
export const useDeleteSpanMapperGroup = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSpanMapperGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperGroupPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteSpanMapperGroup>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperGroupPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteSpanMapperGroupMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Partially updates an existing mapping group's name, condition, or enabled state.
|
||||
* @summary Update a span attribute mapping group
|
||||
*/
|
||||
export const updateSpanMapperGroup = (
|
||||
{ groupId }: UpdateSpanMapperGroupPathParameters,
|
||||
spantypesUpdatableSpanMapperGroupDTO: BodyType<SpantypesUpdatableSpanMapperGroupDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_mapper_groups/${groupId}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesUpdatableSpanMapperGroupDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateSpanMapperGroupMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSpanMapperGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperGroupPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSpanMapperGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperGroupPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateSpanMapperGroup'];
|
||||
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 updateSpanMapperGroup>>,
|
||||
{
|
||||
pathParams: UpdateSpanMapperGroupPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperGroupDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateSpanMapperGroup(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateSpanMapperGroupMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateSpanMapperGroup>>
|
||||
>;
|
||||
export type UpdateSpanMapperGroupMutationBody =
|
||||
BodyType<SpantypesUpdatableSpanMapperGroupDTO>;
|
||||
export type UpdateSpanMapperGroupMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update a span attribute mapping group
|
||||
*/
|
||||
export const useUpdateSpanMapperGroup = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSpanMapperGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperGroupPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSpanMapperGroup>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperGroupPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperGroupDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateSpanMapperGroupMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns all mappers belonging to a mapping group.
|
||||
* @summary List span mappers for a group
|
||||
*/
|
||||
export const listSpanMappers = (
|
||||
{ groupId }: ListSpanMappersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/${groupId}/span_mappers`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSpanMappersQueryKey = ({
|
||||
groupId,
|
||||
}: ListSpanMappersPathParameters) => {
|
||||
return [`/api/v1/span_mapper_groups/${groupId}/span_mappers`] as const;
|
||||
};
|
||||
|
||||
export const getListSpanMappersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSpanMappers>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ groupId }: ListSpanMappersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanMappers>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListSpanMappersQueryKey({ groupId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSpanMappers>>> = ({
|
||||
signal,
|
||||
}) => listSpanMappers({ groupId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!groupId,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanMappers>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSpanMappersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSpanMappers>>
|
||||
>;
|
||||
export type ListSpanMappersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List span mappers for a group
|
||||
*/
|
||||
|
||||
export function useListSpanMappers<
|
||||
TData = Awaited<ReturnType<typeof listSpanMappers>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ groupId }: ListSpanMappersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSpanMappers>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSpanMappersQueryOptions({ groupId }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List span mappers for a group
|
||||
*/
|
||||
export const invalidateListSpanMappers = async (
|
||||
queryClient: QueryClient,
|
||||
{ groupId }: ListSpanMappersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListSpanMappersQueryKey({ groupId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new mapper to the specified mapping group.
|
||||
* @summary Create a span mapper
|
||||
*/
|
||||
export const createSpanMapper = (
|
||||
{ groupId }: CreateSpanMapperPathParameters,
|
||||
spantypesPostableSpanMapperDTO: BodyType<SpantypesPostableSpanMapperDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateSpanMapper201>({
|
||||
url: `/api/v1/span_mapper_groups/${groupId}/span_mappers`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateSpanMapperMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesPostableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesPostableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createSpanMapper'];
|
||||
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 createSpanMapper>>,
|
||||
{
|
||||
pathParams: CreateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesPostableSpanMapperDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createSpanMapper(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateSpanMapperMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createSpanMapper>>
|
||||
>;
|
||||
export type CreateSpanMapperMutationBody =
|
||||
BodyType<SpantypesPostableSpanMapperDTO>;
|
||||
export type CreateSpanMapperMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a span mapper
|
||||
*/
|
||||
export const useCreateSpanMapper = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesPostableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesPostableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateSpanMapperMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Hard-deletes a mapper from a mapping group.
|
||||
* @summary Delete a span mapper
|
||||
*/
|
||||
export const deleteSpanMapper = ({
|
||||
groupId,
|
||||
mapperId,
|
||||
}: DeleteSpanMapperPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_mapper_groups/${groupId}/span_mappers/${mapperId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteSpanMapperMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSpanMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSpanMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteSpanMapper'];
|
||||
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 deleteSpanMapper>>,
|
||||
{ pathParams: DeleteSpanMapperPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteSpanMapper(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteSpanMapperMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteSpanMapper>>
|
||||
>;
|
||||
|
||||
export type DeleteSpanMapperMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a span mapper
|
||||
*/
|
||||
export const useDeleteSpanMapper = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSpanMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteSpanMapper>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSpanMapperPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteSpanMapperMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Partially updates an existing mapper's field context, config, or enabled state.
|
||||
* @summary Update a span mapper
|
||||
*/
|
||||
export const updateSpanMapper = (
|
||||
{ groupId, mapperId }: UpdateSpanMapperPathParameters,
|
||||
spantypesUpdatableSpanMapperDTO: BodyType<SpantypesUpdatableSpanMapperDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/span_mapper_groups/${groupId}/span_mappers/${mapperId}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesUpdatableSpanMapperDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateSpanMapperMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateSpanMapper'];
|
||||
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 updateSpanMapper>>,
|
||||
{
|
||||
pathParams: UpdateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateSpanMapper(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateSpanMapperMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateSpanMapper>>
|
||||
>;
|
||||
export type UpdateSpanMapperMutationBody =
|
||||
BodyType<SpantypesUpdatableSpanMapperDTO>;
|
||||
export type UpdateSpanMapperMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update a span mapper
|
||||
*/
|
||||
export const useUpdateSpanMapper = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSpanMapper>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSpanMapperPathParameters;
|
||||
data: BodyType<SpantypesUpdatableSpanMapperDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateSpanMapperMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -1,70 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#565656;}
|
||||
.st1{fill:url(#SVGID_1_);}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
|
||||
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
|
||||
V131.5z"/>
|
||||
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
|
||||
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
|
||||
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
|
||||
C57.1,141.2,59.1,139.3,59.7,137z"/>
|
||||
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
|
||||
C72.9,121.6,71.5,123,71.5,124.7z"/>
|
||||
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
|
||||
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
|
||||
C92,141.2,93.9,139.3,94.5,137z"/>
|
||||
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
|
||||
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
|
||||
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
|
||||
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
|
||||
C135.9,141.2,137.8,139.3,138.4,137z"/>
|
||||
</g>
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
|
||||
<stop offset="0" style="stop-color:#FCEE1F"/>
|
||||
<stop offset="1" style="stop-color:#F15B2A"/>
|
||||
</linearGradient>
|
||||
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
|
||||
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
|
||||
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
|
||||
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
|
||||
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
|
||||
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
|
||||
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
|
||||
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
|
||||
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
|
||||
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
|
||||
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
|
||||
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
|
||||
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
|
||||
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
|
||||
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
|
||||
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
|
||||
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
|
||||
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
|
||||
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
|
||||
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
|
||||
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
|
||||
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
|
||||
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
|
||||
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
|
||||
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
|
||||
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
|
||||
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
|
||||
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
|
||||
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
|
||||
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
|
||||
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
|
||||
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
|
||||
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
|
||||
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
|
||||
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
|
||||
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
|
||||
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
|
||||
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#565656;}
|
||||
.st1{fill:url(#SVGID_1_);}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
|
||||
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
|
||||
V131.5z"/>
|
||||
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
|
||||
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
|
||||
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
|
||||
C57.1,141.2,59.1,139.3,59.7,137z"/>
|
||||
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
|
||||
C72.9,121.6,71.5,123,71.5,124.7z"/>
|
||||
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
|
||||
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
|
||||
C92,141.2,93.9,139.3,94.5,137z"/>
|
||||
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
|
||||
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
|
||||
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
|
||||
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
|
||||
C135.9,141.2,137.8,139.3,138.4,137z"/>
|
||||
</g>
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
|
||||
<stop offset="0" style="stop-color:#FCEE1F"/>
|
||||
<stop offset="1" style="stop-color:#F15B2A"/>
|
||||
</linearGradient>
|
||||
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
|
||||
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
|
||||
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
|
||||
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
|
||||
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
|
||||
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
|
||||
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
|
||||
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
|
||||
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
|
||||
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
|
||||
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
|
||||
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
|
||||
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
|
||||
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
|
||||
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
|
||||
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
|
||||
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
|
||||
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
|
||||
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
|
||||
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
|
||||
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
|
||||
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
|
||||
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
|
||||
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
|
||||
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
|
||||
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
|
||||
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
|
||||
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
|
||||
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
|
||||
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
|
||||
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
|
||||
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
|
||||
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
|
||||
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
|
||||
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
|
||||
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
|
||||
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
|
||||
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -1,21 +1,21 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<polygon style="fill:#FFD500;" points="382.395,228.568 291.215,228.568 330.762,10.199 129.603,283.43 220.785,283.43
|
||||
181.238,501.799 "/>
|
||||
<g>
|
||||
<path style="fill:#3D3D3D;" d="M181.234,512c-1.355,0-2.726-0.271-4.033-0.833c-4.357-1.878-6.845-6.514-5.999-11.184
|
||||
l37.371-206.353h-78.969c-3.846,0-7.367-2.164-9.103-5.597c-1.735-3.433-1.391-7.55,0.889-10.648L322.548,4.153
|
||||
c2.814-3.822,7.891-5.196,12.25-3.32c4.357,1.878,6.845,6.514,5.999,11.184L303.427,218.37h78.969c3.846,0,7.367,2.164,9.103,5.597
|
||||
c1.735,3.433,1.391,7.55-0.889,10.648L189.451,507.846C187.481,510.523,184.399,512,181.234,512z M149.777,273.231h71.007
|
||||
c3.023,0,5.89,1.341,7.828,3.662c1.938,2.32,2.747,5.38,2.208,8.355l-31.704,175.065l163.105-221.545h-71.007
|
||||
c-3.023,0-5.89-1.341-7.828-3.661c-1.938-2.32-2.747-5.38-2.208-8.355l31.704-175.065L149.777,273.231z"/>
|
||||
<path style="fill:#3D3D3D;" d="M267.666,171.348c-0.604,0-1.215-0.054-1.829-0.165c-5.543-1.004-9.223-6.31-8.22-11.853l0.923-5.1
|
||||
c1.003-5.543,6.323-9.225,11.852-8.219c5.543,1.004,9.223,6.31,8.22,11.853l-0.923,5.1
|
||||
C276.797,167.892,272.503,171.348,267.666,171.348z"/>
|
||||
<path style="fill:#3D3D3D;" d="M255.455,238.77c-0.604,0-1.215-0.054-1.83-0.165c-5.543-1.004-9.222-6.31-8.218-11.853
|
||||
l7.037-38.864c1.004-5.543,6.317-9.225,11.854-8.219c5.543,1.004,9.222,6.31,8.219,11.853l-7.037,38.864
|
||||
C264.587,235.314,260.293,238.77,255.455,238.77z"/>
|
||||
</g>
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<polygon style="fill:#FFD500;" points="382.395,228.568 291.215,228.568 330.762,10.199 129.603,283.43 220.785,283.43
|
||||
181.238,501.799 "/>
|
||||
<g>
|
||||
<path style="fill:#3D3D3D;" d="M181.234,512c-1.355,0-2.726-0.271-4.033-0.833c-4.357-1.878-6.845-6.514-5.999-11.184
|
||||
l37.371-206.353h-78.969c-3.846,0-7.367-2.164-9.103-5.597c-1.735-3.433-1.391-7.55,0.889-10.648L322.548,4.153
|
||||
c2.814-3.822,7.891-5.196,12.25-3.32c4.357,1.878,6.845,6.514,5.999,11.184L303.427,218.37h78.969c3.846,0,7.367,2.164,9.103,5.597
|
||||
c1.735,3.433,1.391,7.55-0.889,10.648L189.451,507.846C187.481,510.523,184.399,512,181.234,512z M149.777,273.231h71.007
|
||||
c3.023,0,5.89,1.341,7.828,3.662c1.938,2.32,2.747,5.38,2.208,8.355l-31.704,175.065l163.105-221.545h-71.007
|
||||
c-3.023,0-5.89-1.341-7.828-3.661c-1.938-2.32-2.747-5.38-2.208-8.355l31.704-175.065L149.777,273.231z"/>
|
||||
<path style="fill:#3D3D3D;" d="M267.666,171.348c-0.604,0-1.215-0.054-1.829-0.165c-5.543-1.004-9.223-6.31-8.22-11.853l0.923-5.1
|
||||
c1.003-5.543,6.323-9.225,11.852-8.219c5.543,1.004,9.223,6.31,8.22,11.853l-0.923,5.1
|
||||
C276.797,167.892,272.503,171.348,267.666,171.348z"/>
|
||||
<path style="fill:#3D3D3D;" d="M255.455,238.77c-0.604,0-1.215-0.054-1.83-0.165c-5.543-1.004-9.222-6.31-8.218-11.853
|
||||
l7.037-38.864c1.004-5.543,6.317-9.225,11.854-8.219c5.543,1.004,9.222,6.31,8.219,11.853l-7.037,38.864
|
||||
C264.587,235.314,260.293,238.77,255.455,238.77z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,4 +1,6 @@
|
||||
.log-state-indicator {
|
||||
padding-left: 8px;
|
||||
|
||||
.line {
|
||||
margin: 0 8px;
|
||||
min-height: 24px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +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';
|
||||
|
||||
type UseLogsTableColumnsProps = {
|
||||
fields: IField[];
|
||||
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,
|
||||
canBeHidden: false,
|
||||
width: { fixed: 24 },
|
||||
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: { default: 170, min: 170 },
|
||||
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,
|
||||
canBeHidden: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
data-active={isActive}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
return [
|
||||
stateIndicatorCol,
|
||||
...(timestampCol ? [timestampCol] : []),
|
||||
...(appendTo === 'center' ? fieldColumns : []),
|
||||
...(bodyCol ? [bodyCol] : []),
|
||||
...(appendTo === 'end' ? fieldColumns : []),
|
||||
];
|
||||
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { ComponentProps, memo } from 'react';
|
||||
import { TableComponents } from 'react-virtuoso';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TanStackRowCells from './TanStackRow';
|
||||
import {
|
||||
useClearRowHovered,
|
||||
useSetRowHovered,
|
||||
} from './TanStackTableStateContext';
|
||||
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>({
|
||||
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}>
|
||||
<TanStackRowCells
|
||||
row={item.row}
|
||||
itemKind={item.kind}
|
||||
context={context}
|
||||
hasSingleColumn={context?.hasSingleColumn ?? false}
|
||||
columnOrderKey={context?.columnOrderKey ?? ''}
|
||||
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
||||
/>
|
||||
</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}
|
||||
>
|
||||
<TanStackRowCells
|
||||
row={item.row}
|
||||
itemKind={item.kind}
|
||||
context={context}
|
||||
hasSingleColumn={context?.hasSingleColumn ?? false}
|
||||
columnOrderKey={context?.columnOrderKey ?? ''}
|
||||
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom comparison - only re-render when row identity or computed values change
|
||||
// This looks overkill but ensures the table is stable and doesn't re-render on every change
|
||||
// If you add any new prop to context, remember to update this function
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function areTableRowPropsEqual<TData>(
|
||||
prev: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
next: Readonly<VirtuosoTableRowProps<TData>>,
|
||||
): boolean {
|
||||
if (prev.item.row.id !== next.item.row.id) {
|
||||
return false;
|
||||
}
|
||||
if (prev.item.kind !== next.item.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevData = prev.item.row.original;
|
||||
const nextData = next.item.row.original;
|
||||
|
||||
if (prevData !== nextData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.context?.hasSingleColumn !== next.context?.hasSingleColumn) {
|
||||
return false;
|
||||
}
|
||||
if (prev.context?.columnOrderKey !== next.context?.columnOrderKey) {
|
||||
return false;
|
||||
}
|
||||
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.context !== next.context) {
|
||||
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;
|
||||
@@ -1,260 +0,0 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
|
||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||
import cx from 'classnames';
|
||||
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||
|
||||
import { SortState, TableColumnDef } from './types';
|
||||
|
||||
import headerStyles from './TanStackHeaderRow.module.scss';
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type TanStackHeaderRowProps<TData = unknown> = {
|
||||
column: TableColumnDef<TData>;
|
||||
header?: TanStackHeader<TData, unknown>;
|
||||
isDarkMode: boolean;
|
||||
hasSingleColumn: boolean;
|
||||
canRemoveColumn?: boolean;
|
||||
onRemoveColumn?: (columnId: string) => void;
|
||||
orderBy?: SortState | null;
|
||||
onSort?: (sort: SortState | null) => void;
|
||||
/** Last column cannot be resized */
|
||||
isLastColumn?: boolean;
|
||||
};
|
||||
|
||||
const GRIP_ICON_SIZE = 12;
|
||||
|
||||
const SORT_ICON_SIZE = 14;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackHeaderRow<TData>({
|
||||
column,
|
||||
header,
|
||||
isDarkMode,
|
||||
hasSingleColumn,
|
||||
canRemoveColumn = false,
|
||||
onRemoveColumn,
|
||||
orderBy,
|
||||
onSort,
|
||||
isLastColumn = false,
|
||||
}: TanStackHeaderRowProps<TData>): JSX.Element {
|
||||
const columnId = column.id;
|
||||
const isDragColumn = column.enableMove !== false && column.pin == null;
|
||||
const isResizableColumn =
|
||||
!isLastColumn &&
|
||||
column.enableResize !== false &&
|
||||
Boolean(header?.column.getCanResize());
|
||||
const isColumnRemovable = Boolean(
|
||||
canRemoveColumn && onRemoveColumn && column.enableRemove,
|
||||
);
|
||||
const isSortable = column.enableSort === true && Boolean(onSort);
|
||||
const currentSortDirection =
|
||||
orderBy?.columnName === columnId ? orderBy.order : null;
|
||||
const isResizing = Boolean(header?.column.getIsResizing());
|
||||
const resizeHandler = header?.getResizeHandler();
|
||||
const headerText =
|
||||
typeof column.header === 'string' && column.header
|
||||
? column.header
|
||||
: String(header?.id ?? columnId);
|
||||
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const handleSortClick = useCallback((): void => {
|
||||
if (!isSortable || !onSort) {
|
||||
return;
|
||||
}
|
||||
if (currentSortDirection === null) {
|
||||
onSort({ columnName: columnId, order: 'asc' });
|
||||
} else if (currentSortDirection === 'asc') {
|
||||
onSort({ columnName: columnId, order: 'desc' });
|
||||
} else {
|
||||
onSort(null);
|
||||
}
|
||||
}, [isSortable, onSort, currentSortDirection, columnId]);
|
||||
|
||||
const handleResizeStart = (
|
||||
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
||||
): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeHandler?.(event);
|
||||
};
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: columnId,
|
||||
disabled: !isDragColumn,
|
||||
});
|
||||
const headerCellStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
||||
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
||||
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
||||
}) 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,
|
||||
isSortable && headerStyles.isSortable,
|
||||
);
|
||||
|
||||
const thClassName = cx(
|
||||
tableStyles.tableHeaderCell,
|
||||
headerCellClassName,
|
||||
column.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<th
|
||||
ref={setNodeRef}
|
||||
className={thClassName}
|
||||
key={columnId}
|
||||
style={headerCellStyle}
|
||||
data-dark-mode={isDarkMode}
|
||||
data-single-column={hasSingleColumn || undefined}
|
||||
>
|
||||
<span className={headerContentClassName}>
|
||||
{isDragColumn ? (
|
||||
<span className={headerStyles.tanstackGripSlot}>
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
role="button"
|
||||
aria-label={`Drag ${String(
|
||||
(typeof column.header === 'string' && column.header) ||
|
||||
header?.id ||
|
||||
columnId,
|
||||
)} column`}
|
||||
className={headerStyles.tanstackGripActivator}
|
||||
>
|
||||
<GripVertical size={GRIP_ICON_SIZE} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{isSortable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'tanstack-header-title',
|
||||
headerStyles.tanstackSortButton,
|
||||
currentSortDirection && headerStyles.isSorted,
|
||||
)}
|
||||
title={headerTitleAttr}
|
||||
onClick={handleSortClick}
|
||||
data-sort={
|
||||
currentSortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: currentSortDirection === 'desc'
|
||||
? 'descending'
|
||||
: 'none'
|
||||
}
|
||||
>
|
||||
<span className={headerStyles.tanstackSortLabel}>
|
||||
{header?.column?.columnDef
|
||||
? flexRender(header.column.columnDef.header, header.getContext())
|
||||
: typeof column.header === 'function'
|
||||
? column.header()
|
||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className={headerStyles.tanstackSortIndicator}>
|
||||
{currentSortDirection === 'asc' ? (
|
||||
<ChevronUp size={SORT_ICON_SIZE} />
|
||||
) : currentSortDirection === 'desc' ? (
|
||||
<ChevronDown size={SORT_ICON_SIZE} />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cx('tanstack-header-title', headerStyles.tanstackHeaderTitle)}
|
||||
title={headerTitleAttr}
|
||||
>
|
||||
{header?.column?.columnDef
|
||||
? flexRender(header.column.columnDef.header, header.getContext())
|
||||
: typeof column.header === 'function'
|
||||
? column.header()
|
||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
)}
|
||||
{isColumnRemovable && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
aria-label={`Column actions for ${headerTitleAttr}`}
|
||||
className={headerStyles.tanstackHeaderActionTrigger}
|
||||
onMouseDown={(event): void => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className={headerStyles.tanstackColumnActionsContent}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={headerStyles.tanstackRemoveColumnAction}
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemoveColumn?.(column.id);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined
|
||||
className={headerStyles.tanstackRemoveColumnActionIcon}
|
||||
/>
|
||||
Remove column
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
{isResizableColumn && (
|
||||
<span
|
||||
role="presentation"
|
||||
className={headerStyles.cursorColResize}
|
||||
title="Drag to resize column"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
onTouchStart={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
>
|
||||
<span className={headerStyles.tanstackResizeHandleLine} />
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackHeaderRow;
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Row as TanStackRowModel } from '@tanstack/react-table';
|
||||
|
||||
import { TanStackRowCell } from './TanStackRowCell';
|
||||
import { useIsRowHovered } from './TanStackTableStateContext';
|
||||
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;
|
||||
columnOrderKey: string;
|
||||
columnVisibilityKey: string;
|
||||
};
|
||||
|
||||
function TanStackRowCellsInner<TData>({
|
||||
row,
|
||||
context,
|
||||
itemKind,
|
||||
hasSingleColumn,
|
||||
columnOrderKey: _columnOrderKey,
|
||||
columnVisibilityKey: _columnVisibilityKey,
|
||||
}: TanStackRowCellsProps<TData>): JSX.Element {
|
||||
const hasHovered = useIsRowHovered(row.id);
|
||||
const rowData = row.original;
|
||||
const visibleCells = row.getVisibleCells();
|
||||
const lastCellIndex = visibleCells.length - 1;
|
||||
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLTableCellElement>) => {
|
||||
const keyData = getRowKeyData?.(rowIndex);
|
||||
const itemKey = keyData?.itemKey ?? '';
|
||||
|
||||
// Handle ctrl+click or cmd+click (open in new tab)
|
||||
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
|
||||
onRowClickNewTab(rowData, itemKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
const keyData = getRowKeyData?.(rowIndex);
|
||||
return (
|
||||
<td
|
||||
colSpan={context?.colCount ?? 1}
|
||||
className={tableStyles.tableCellExpansion}
|
||||
>
|
||||
{context?.renderExpandedRow?.(
|
||||
rowData,
|
||||
keyData?.finalKey ?? '',
|
||||
keyData?.groupMeta,
|
||||
)}
|
||||
</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
|
||||
// If you add any new prop to context, remember to update this function
|
||||
function areRowCellsPropsEqual<TData>(
|
||||
prev: Readonly<TanStackRowCellsProps<TData>>,
|
||||
next: Readonly<TanStackRowCellsProps<TData>>,
|
||||
): boolean {
|
||||
return (
|
||||
prev.row.id === next.row.id &&
|
||||
prev.itemKind === next.itemKind &&
|
||||
prev.hasSingleColumn === next.hasSingleColumn &&
|
||||
prev.columnOrderKey === next.columnOrderKey &&
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
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;
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
import type { Cell } from '@tanstack/react-table';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useShouldShowCellSkeleton } from './TanStackTableStateContext';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
import skeletonStyles from './TanStackTableSkeleton.module.scss';
|
||||
|
||||
export type TanStackRowCellProps<TData> = {
|
||||
cell: Cell<TData, unknown>;
|
||||
hasSingleColumn: boolean;
|
||||
isLastCell: boolean;
|
||||
hasHovered: boolean;
|
||||
rowData: TData;
|
||||
onClick: (event: MouseEvent<HTMLTableCellElement>) => void;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
};
|
||||
|
||||
function TanStackRowCellInner<TData>({
|
||||
cell,
|
||||
hasSingleColumn,
|
||||
isLastCell,
|
||||
hasHovered,
|
||||
rowData,
|
||||
onClick,
|
||||
renderRowActions,
|
||||
}: TanStackRowCellProps<TData>): JSX.Element {
|
||||
const showSkeleton = useShouldShowCellSkeleton();
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
|
||||
data-single-column={hasSingleColumn || undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={skeletonStyles.cellSkeleton}
|
||||
/>
|
||||
) : (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
)}
|
||||
{isLastCell && hasHovered && renderRowActions && !showSkeleton && (
|
||||
<span className={tableStyles.tableViewRowActions}>
|
||||
{renderRowActions(rowData)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom comparison - only re-render when row data changes
|
||||
// If you add any new prop to context, remember to update this function
|
||||
function areTanStackRowCellPropsEqual<TData>(
|
||||
prev: Readonly<TanStackRowCellProps<TData>>,
|
||||
next: Readonly<TanStackRowCellProps<TData>>,
|
||||
): boolean {
|
||||
if (next.cell.id.startsWith('skeleton-')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,100 +0,0 @@
|
||||
.tanStackTable {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
|
||||
& 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;
|
||||
overflow-anchor: none;
|
||||
|
||||
&: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;
|
||||
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;
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { TableComponents } from 'react-virtuoso';
|
||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { DndContext, pointerWithin } from '@dnd-kit/core';
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
ComboboxSimple,
|
||||
ComboboxSimpleItem,
|
||||
TooltipProvider,
|
||||
} from '@signozhq/ui';
|
||||
import { Pagination } from '@signozhq/ui';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnPinningState,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import TanStackCustomTableRow from './TanStackCustomTableRow';
|
||||
import TanStackHeaderRow from './TanStackHeaderRow';
|
||||
import {
|
||||
ColumnVisibilitySync,
|
||||
TableLoadingSync,
|
||||
TanStackTableStateProvider,
|
||||
} from './TanStackTableStateContext';
|
||||
import {
|
||||
FlatItem,
|
||||
TableRowContext,
|
||||
TanStackTableHandle,
|
||||
TanStackTableProps,
|
||||
} from './types';
|
||||
import { useColumnDnd } from './useColumnDnd';
|
||||
import { useColumnHandlers } from './useColumnHandlers';
|
||||
import { useColumnState } from './useColumnState';
|
||||
import { useEffectiveData } from './useEffectiveData';
|
||||
import { useFlatItems } from './useFlatItems';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
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 noopColumnVisibility = (): void => {};
|
||||
|
||||
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
|
||||
(value) => ({
|
||||
value: value.toString(),
|
||||
label: value.toString(),
|
||||
displayValue: value.toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackTableInner<TData>(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
columnSizing: columnSizingProp,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
isLoading = false,
|
||||
skeletonRowCount = 10,
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
getRowCanExpand,
|
||||
tableScrollerProps,
|
||||
plainTextCellLineClamp,
|
||||
cellTypographySize,
|
||||
className,
|
||||
testId,
|
||||
prefixPaginationContent,
|
||||
suffixPaginationContent,
|
||||
}: TanStackTableProps<TData>,
|
||||
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
|
||||
): JSX.Element {
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
expanded,
|
||||
setExpanded,
|
||||
} = useTableParams(enableQueryParams, {
|
||||
page: pagination?.defaultPage,
|
||||
limit: pagination?.defaultLimit,
|
||||
});
|
||||
|
||||
const isGrouped = (groupBy?.length ?? 0) > 0;
|
||||
|
||||
const {
|
||||
columnVisibility: storeVisibility,
|
||||
columnSizing: storeSizing,
|
||||
sortedColumns,
|
||||
hideColumn,
|
||||
setColumnSizing: storeSetSizing,
|
||||
setColumnOrder: storeSetOrder,
|
||||
} = useColumnState({
|
||||
storageKey: columnStorageKey,
|
||||
columns,
|
||||
isGrouped,
|
||||
});
|
||||
|
||||
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
|
||||
const effectiveColumns = columnStorageKey ? sortedColumns : columns;
|
||||
const effectiveVisibility = columnStorageKey ? storeVisibility : {};
|
||||
const effectiveSizing = columnStorageKey
|
||||
? storeSizing
|
||||
: columnSizingProp ?? {};
|
||||
|
||||
const effectiveData = useEffectiveData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
limit,
|
||||
skeletonRowCount,
|
||||
});
|
||||
|
||||
const { rowKeyData, getRowKeyData } = useRowKeyData({
|
||||
data: effectiveData,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
});
|
||||
|
||||
const {
|
||||
handleColumnSizingChange,
|
||||
handleColumnOrderChange,
|
||||
handleRemoveColumn,
|
||||
} = useColumnHandlers({
|
||||
columnStorageKey,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
hideColumn,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
});
|
||||
|
||||
const columnPinning = useMemo<ColumnPinningState>(
|
||||
() => ({
|
||||
left: effectiveColumns.filter((c) => c.pin === 'left').map((c) => c.id),
|
||||
right: effectiveColumns.filter((c) => c.pin === 'right').map((c) => c.id),
|
||||
}),
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
);
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: TData, index: number): string => {
|
||||
if (rowKeyData) {
|
||||
return rowKeyData[index]?.finalKey ?? String(index);
|
||||
}
|
||||
const r = row as Record<string, unknown>;
|
||||
if (r != null && typeof r.id !== 'undefined') {
|
||||
return String(r.id);
|
||||
}
|
||||
return String(index);
|
||||
},
|
||||
[rowKeyData],
|
||||
);
|
||||
|
||||
const tableGetRowCanExpand = useCallback(
|
||||
(row: Row<TData>): boolean =>
|
||||
getRowCanExpand ? getRowCanExpand(row.original) : true,
|
||||
[getRowCanExpand],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: effectiveData,
|
||||
columns: tanstackColumns,
|
||||
enableColumnResizing: true,
|
||||
enableColumnPinning: true,
|
||||
columnResizeMode: 'onChange',
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
enableExpanding: Boolean(renderExpandedRow),
|
||||
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
|
||||
onColumnSizingChange: handleColumnSizingChange,
|
||||
onColumnVisibilityChange: noopColumnVisibility,
|
||||
onExpandedChange: setExpanded,
|
||||
state: {
|
||||
columnSizing: effectiveSizing,
|
||||
columnVisibility: effectiveVisibility,
|
||||
columnPinning,
|
||||
expanded,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep refs to avoid recreating virtuosoComponents on every resize/render
|
||||
const tableRef = useRef(table);
|
||||
tableRef.current = table;
|
||||
const columnsRef = useRef(effectiveColumns);
|
||||
columnsRef.current = effectiveColumns;
|
||||
|
||||
const tableRows = table.getRowModel().rows;
|
||||
|
||||
const { flatItems, flatIndexForActiveRow } = useFlatItems({
|
||||
tableRows,
|
||||
renderExpandedRow,
|
||||
expanded,
|
||||
activeRowIndex,
|
||||
});
|
||||
|
||||
// keep previous count just to avoid flashing the pagination component
|
||||
const prevTotalCountRef = useRef(pagination?.total || 0);
|
||||
if (pagination?.total && pagination?.total > 0) {
|
||||
prevTotalCountRef.current = pagination?.total;
|
||||
}
|
||||
const effectiveTotalCount = !isLoading
|
||||
? pagination?.total || 0
|
||||
: prevTotalCountRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (flatIndexForActiveRow < 0) {
|
||||
return;
|
||||
}
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: flatIndexForActiveRow,
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}, [flatIndexForActiveRow]);
|
||||
|
||||
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
|
||||
columns: effectiveColumns,
|
||||
onColumnOrderChange: handleColumnOrderChange,
|
||||
});
|
||||
|
||||
const hasSingleColumn = useMemo(
|
||||
() =>
|
||||
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
|
||||
1,
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const canRemoveColumn = !hasSingleColumn;
|
||||
|
||||
const flatHeaders = useMemo(
|
||||
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tanstackColumns, columnPinning, effectiveVisibility],
|
||||
);
|
||||
|
||||
const columnsById = useMemo(
|
||||
() => new Map(effectiveColumns.map((c) => [c.id, c] as const)),
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const visibleColumnsCount = table.getVisibleFlatColumns().length;
|
||||
|
||||
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
|
||||
const columnVisibilityKey = useMemo(
|
||||
() =>
|
||||
table
|
||||
.getVisibleFlatColumns()
|
||||
.map((c) => c.id)
|
||||
.join(','),
|
||||
// we want to explicitly have table out of this deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[effectiveVisibility, columnIds],
|
||||
);
|
||||
|
||||
const virtuosoContext = useMemo<TableRowContext<TData>>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
isDarkMode,
|
||||
plainTextCellLineClamp,
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
}),
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
isDarkMode,
|
||||
plainTextCellLineClamp,
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
],
|
||||
);
|
||||
|
||||
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, index) => {
|
||||
const column = columnsById.get(header.id);
|
||||
if (!column) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TanStackHeaderRow
|
||||
key={header.id}
|
||||
column={column}
|
||||
header={header}
|
||||
isDarkMode={isDarkMode}
|
||||
hasSingleColumn={hasSingleColumn}
|
||||
onRemoveColumn={handleRemoveColumn}
|
||||
canRemoveColumn={canRemoveColumn}
|
||||
orderBy={orderBy}
|
||||
onSort={setOrderBy}
|
||||
isLastColumn={index === flatHeaders.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}, [
|
||||
sensors,
|
||||
handleDragEnd,
|
||||
columnIds,
|
||||
flatHeaders,
|
||||
columnsById,
|
||||
isDarkMode,
|
||||
hasSingleColumn,
|
||||
handleRemoveColumn,
|
||||
canRemoveColumn,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
]);
|
||||
|
||||
const handleEndReached = useCallback(
|
||||
(index: number): void => {
|
||||
onEndReached?.(index);
|
||||
},
|
||||
[onEndReached],
|
||||
);
|
||||
|
||||
const isInfiniteScrollMode = Boolean(onEndReached);
|
||||
const showInfiniteScrollLoader = isInfiniteScrollMode && isLoading;
|
||||
|
||||
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']>
|
||||
>;
|
||||
|
||||
// Use refs in virtuosoComponents to keep the component reference stable during resize
|
||||
// This prevents Virtuoso from re-rendering all rows when columns are resized
|
||||
const virtuosoComponents = useMemo(
|
||||
() => ({
|
||||
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
|
||||
<table className={tableStyles.tanStackTable} style={style}>
|
||||
<VirtuosoTableColGroup
|
||||
columns={columnsRef.current}
|
||||
table={tableRef.current}
|
||||
/>
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
TableRow: TanStackCustomTableRow,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
|
||||
<TanStackTableStateProvider>
|
||||
<TableLoadingSync
|
||||
isLoading={isLoading}
|
||||
isInfiniteScrollMode={isInfiniteScrollMode}
|
||||
/>
|
||||
<ColumnVisibilitySync visibility={effectiveVisibility} />
|
||||
<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}
|
||||
style={virtuosoTableStyle}
|
||||
components={virtuosoComponents}
|
||||
endReached={onEndReached ? handleEndReached : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showInfiniteScrollLoader && (
|
||||
<div
|
||||
className={viewStyles.tanstackLoadingOverlay}
|
||||
data-testid="tanstack-infinite-loader"
|
||||
>
|
||||
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
|
||||
</div>
|
||||
)}
|
||||
{showPagination && pagination && (
|
||||
<div className={viewStyles.paginationContainer}>
|
||||
{prefixPaginationContent}
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={limit}
|
||||
total={effectiveTotalCount}
|
||||
onPageChange={(p): void => {
|
||||
setPage(p);
|
||||
}}
|
||||
/>
|
||||
<div className={viewStyles.paginationPageSize}>
|
||||
<ComboboxSimple
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => setLimit(+value)}
|
||||
items={paginationPageSizeItems}
|
||||
/>
|
||||
</div>
|
||||
{suffixPaginationContent}
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</TanStackTableStateProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
|
||||
props: TanStackTableProps<TData> & {
|
||||
ref?: React.Ref<TanStackTableHandle>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
|
||||
export const TanStackTableBase = memo(
|
||||
TanStackTableForward,
|
||||
) as typeof TanStackTableForward;
|
||||
@@ -1,21 +0,0 @@
|
||||
.headerSkeleton {
|
||||
width: 60% !important;
|
||||
min-width: 50px !important;
|
||||
height: 16px !important;
|
||||
|
||||
:global(.ant-skeleton-input) {
|
||||
min-width: 50px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cellSkeleton {
|
||||
width: 80% !important;
|
||||
min-width: 40px !important;
|
||||
height: 14px !important;
|
||||
|
||||
:global(.ant-skeleton-input) {
|
||||
min-width: 40px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
import { getColumnWidthStyle } from './utils';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
import styles from './TanStackTableSkeleton.module.scss';
|
||||
|
||||
type TanStackTableSkeletonProps<TData> = {
|
||||
columns: TableColumnDef<TData>[];
|
||||
rowCount: number;
|
||||
isDarkMode: boolean;
|
||||
columnSizing?: ColumnSizingState;
|
||||
};
|
||||
|
||||
export function TanStackTableSkeleton<TData>({
|
||||
columns,
|
||||
rowCount,
|
||||
isDarkMode,
|
||||
columnSizing,
|
||||
}: TanStackTableSkeletonProps<TData>): JSX.Element {
|
||||
const rows = useMemo(() => Array.from({ length: rowCount }, (_, i) => i), [
|
||||
rowCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<table className={tableStyles.tanStackTable}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col
|
||||
key={column.id}
|
||||
style={getColumnWidthStyle(
|
||||
column,
|
||||
columnSizing?.[column.id],
|
||||
index === columns.length - 1,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
className={tableStyles.tableHeaderCell}
|
||||
data-dark-mode={isDarkMode}
|
||||
>
|
||||
{typeof column.header === 'function' ? (
|
||||
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((rowIndex) => (
|
||||
<tr key={rowIndex} className={tableStyles.tableRow}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.id} className={tableStyles.tableCell}>
|
||||
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
/* eslint-enable no-restricted-imports */
|
||||
import { VisibilityState } from '@tanstack/react-table';
|
||||
import { createStore, StoreApi, useStore } from 'zustand';
|
||||
|
||||
const CLEAR_HOVER_DELAY_MS = 100;
|
||||
|
||||
type TanStackTableState = {
|
||||
hoveredRowId: string | null;
|
||||
clearTimeoutId: ReturnType<typeof setTimeout> | null;
|
||||
setHoveredRowId: (id: string | null) => void;
|
||||
scheduleClearHover: (rowId: string) => void;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
isInfiniteScrollMode: boolean;
|
||||
setIsInfiniteScrollMode: (enabled: boolean) => void;
|
||||
columnVisibility: VisibilityState;
|
||||
setColumnVisibility: (visibility: VisibilityState) => void;
|
||||
};
|
||||
|
||||
const createTableStateStore = (): StoreApi<TanStackTableState> =>
|
||||
createStore<TanStackTableState>((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 });
|
||||
},
|
||||
isLoading: false,
|
||||
setIsLoading: (loading: boolean): void => {
|
||||
set({ isLoading: loading });
|
||||
},
|
||||
isInfiniteScrollMode: false,
|
||||
setIsInfiniteScrollMode: (enabled: boolean): void => {
|
||||
set({ isInfiniteScrollMode: enabled });
|
||||
},
|
||||
columnVisibility: {},
|
||||
setColumnVisibility: (visibility: VisibilityState): void => {
|
||||
set({ columnVisibility: visibility });
|
||||
},
|
||||
}));
|
||||
|
||||
type TableStateStore = StoreApi<TanStackTableState>;
|
||||
|
||||
const TanStackTableStateContext = createContext<TableStateStore | null>(null);
|
||||
|
||||
export function TanStackTableStateProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const storeRef = useRef<TableStateStore | null>(null);
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createTableStateStore();
|
||||
}
|
||||
return (
|
||||
<TanStackTableStateContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</TanStackTableStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultStore = createTableStateStore();
|
||||
|
||||
export const useIsRowHovered = (rowId: string): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
const isHovered = useStore(
|
||||
store ?? defaultStore,
|
||||
(s) => s.hoveredRowId === rowId,
|
||||
);
|
||||
return store ? isHovered : false;
|
||||
};
|
||||
|
||||
export const useSetRowHovered = (rowId: string): (() => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
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(TanStackTableStateContext);
|
||||
return useCallback(() => {
|
||||
if (store) {
|
||||
store.getState().scheduleClearHover(rowId);
|
||||
}
|
||||
}, [store, rowId]);
|
||||
};
|
||||
|
||||
export const useIsTableLoading = (): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(store ?? defaultStore, (s) => s.isLoading);
|
||||
};
|
||||
|
||||
export const useSetTableLoading = (): ((loading: boolean) => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useCallback(
|
||||
(loading: boolean) => {
|
||||
if (store) {
|
||||
store.getState().setIsLoading(loading);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
};
|
||||
|
||||
export function TableLoadingSync({
|
||||
isLoading,
|
||||
isInfiniteScrollMode,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isInfiniteScrollMode: boolean;
|
||||
}): null {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
|
||||
// Sync on mount and when props change
|
||||
useEffect(() => {
|
||||
if (store) {
|
||||
store.getState().setIsLoading(isLoading);
|
||||
store.getState().setIsInfiniteScrollMode(isInfiniteScrollMode);
|
||||
}
|
||||
}, [isLoading, isInfiniteScrollMode, store]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useShouldShowCellSkeleton = (): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(
|
||||
store ?? defaultStore,
|
||||
(s) => s.isLoading && !s.isInfiniteScrollMode,
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumnVisibility = (): VisibilityState => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(store ?? defaultStore, (s) => s.columnVisibility);
|
||||
};
|
||||
|
||||
export const useIsColumnVisible = (columnId: string): boolean => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useStore(
|
||||
store ?? defaultStore,
|
||||
(s) => s.columnVisibility[columnId] !== false,
|
||||
);
|
||||
};
|
||||
|
||||
export const useSetColumnVisibility = (): ((
|
||||
visibility: VisibilityState,
|
||||
) => void) => {
|
||||
const store = useContext(TanStackTableStateContext);
|
||||
return useCallback(
|
||||
(visibility: VisibilityState) => {
|
||||
if (store) {
|
||||
store.getState().setColumnVisibility(visibility);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
};
|
||||
|
||||
export function ColumnVisibilitySync({
|
||||
visibility,
|
||||
}: {
|
||||
visibility: VisibilityState;
|
||||
}): null {
|
||||
const setVisibility = useSetColumnVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
setVisibility(visibility);
|
||||
}, [visibility, setVisibility]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default TanStackTableStateContext;
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
|
||||
type BaseProps = Omit<
|
||||
HTMLAttributes<HTMLSpanElement>,
|
||||
'children' | 'dangerouslySetInnerHTML'
|
||||
> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type WithChildren = BaseProps & {
|
||||
children: ReactNode;
|
||||
dangerouslySetInnerHTML?: never;
|
||||
};
|
||||
|
||||
type WithDangerousHtml = BaseProps & {
|
||||
children?: never;
|
||||
dangerouslySetInnerHTML: { __html: string };
|
||||
};
|
||||
|
||||
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
|
||||
|
||||
function TanStackTableText({
|
||||
children,
|
||||
className,
|
||||
dangerouslySetInnerHTML,
|
||||
...rest
|
||||
}: TanStackTableTextProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(tableStyles.tableCellText, className)}
|
||||
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackTableText;
|
||||
@@ -1,152 +0,0 @@
|
||||
.tanstackTableViewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
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: auto;
|
||||
}
|
||||
|
||||
.tanstackTableFootLoaderCell {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tanstackTableVirtuosoScroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.paginationPageSize {
|
||||
width: 80px;
|
||||
--combobox-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
|
||||
import type { TableColumnDef } from './types';
|
||||
import { getColumnWidthStyle } from './utils';
|
||||
|
||||
export function VirtuosoTableColGroup<TData>({
|
||||
columns,
|
||||
table,
|
||||
}: {
|
||||
columns: TableColumnDef<TData>[];
|
||||
table: Table<TData>;
|
||||
}): JSX.Element {
|
||||
const visibleTanstackColumns = table.getVisibleFlatColumns();
|
||||
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
{visibleTanstackColumns.map((tanstackCol, index) => {
|
||||
const colDef = columnDefsById.get(tanstackCol.id);
|
||||
if (!colDef) {
|
||||
return <col key={tanstackCol.id} />;
|
||||
}
|
||||
const persistedWidth = columnSizing[tanstackCol.id];
|
||||
const isLastColumn = index === visibleTanstackColumns.length - 1;
|
||||
return (
|
||||
<col
|
||||
key={tanstackCol.id}
|
||||
style={getColumnWidthStyle(colDef, persistedWidth, isLastColumn)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
jest.mock('../TanStackTable.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
tableRow: 'tableRow',
|
||||
tableRowActive: 'tableRowActive',
|
||||
tableRowExpansion: 'tableRowExpansion',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../TanStackRow', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<td data-testid="mocked-row-cells">mocked cells</td>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSetRowHovered = jest.fn();
|
||||
const mockClearRowHovered = jest.fn();
|
||||
|
||||
jest.mock('../TanStackTableStateContext', () => ({
|
||||
useSetRowHovered: (_rowId: string): (() => void) => mockSetRowHovered,
|
||||
useClearRowHovered: (_rowId: string): (() => void) => mockClearRowHovered,
|
||||
}));
|
||||
|
||||
import { fireEvent, 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 }, id } as never,
|
||||
});
|
||||
|
||||
const virtuosoAttrs = {
|
||||
'data-index': 0,
|
||||
'data-item-index': 0,
|
||||
'data-known-size': 40,
|
||||
} as const;
|
||||
|
||||
const baseContext: TableRowContext<{ id: string }> = {
|
||||
colCount: 1,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: 'col1',
|
||||
columnVisibilityKey: 'col1',
|
||||
};
|
||||
|
||||
describe('TanStackCustomTableRow', () => {
|
||||
beforeEach(() => {
|
||||
mockSetRowHovered.mockClear();
|
||||
mockClearRowHovered.mockClear();
|
||||
});
|
||||
|
||||
it('renders cells via TanStackRowCells', async () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('does not apply active class when isRowActive returns false', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
isRowActive: (row) => row.id === 'other',
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('renders expansion row with expansion class', () => {
|
||||
const item: FlatItem<{ id: string }> = {
|
||||
kind: 'expansion',
|
||||
row: { original: { id: '1' }, id: '1' } as never,
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={item}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
|
||||
});
|
||||
|
||||
describe('hover state management', () => {
|
||||
it('calls setRowHovered on mouse enter', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(mockSetRowHovered).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls clearRowHovered on mouse leave', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
fireEvent.mouseLeave(row);
|
||||
expect(mockClearRowHovered).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('virtuoso integration', () => {
|
||||
it('forwards data-index attribute to tr element', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveAttribute('data-index', '0');
|
||||
});
|
||||
|
||||
it('forwards data-item-index attribute to tr element', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveAttribute('data-item-index', '0');
|
||||
});
|
||||
|
||||
it('forwards data-known-size attribute to tr element', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={baseContext}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveAttribute('data-known-size', '40');
|
||||
});
|
||||
});
|
||||
|
||||
describe('row interaction', () => {
|
||||
it('applies custom style from getRowStyle in context', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
getRowStyle: () => ({ backgroundColor: 'red' }),
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveStyle({ backgroundColor: 'red' });
|
||||
});
|
||||
|
||||
it('applies custom className from getRowClassName in context', () => {
|
||||
const ctx: TableRowContext<{ id: string }> = {
|
||||
...baseContext,
|
||||
getRowClassName: () => 'custom-row-class',
|
||||
};
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<TanStackCustomTableRow
|
||||
{...virtuosoAttrs}
|
||||
item={makeItem('1')}
|
||||
context={ctx}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
const row = container.querySelector('tr')!;
|
||||
expect(row).toHaveClass('custom-row-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,368 +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();
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
const sortableCol = col('sortable', { enableSort: true, header: 'Sortable' });
|
||||
const sortableHeader = {
|
||||
id: 'sortable',
|
||||
column: {
|
||||
id: 'sortable',
|
||||
getCanResize: (): boolean => true,
|
||||
getIsResizing: (): boolean => false,
|
||||
columnDef: { header: 'Sortable', enableSort: true },
|
||||
},
|
||||
getResizeHandler: (): jest.Mock => jest.fn(),
|
||||
getContext: (): Record<string, unknown> => ({}),
|
||||
} as never;
|
||||
|
||||
it('calls onSort with asc when clicking unsorted column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSort = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
// Sort button uses the column header as title
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
await user.click(sortButton);
|
||||
expect(onSort).toHaveBeenCalledWith({
|
||||
columnName: 'sortable',
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSort with desc when clicking asc-sorted column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSort = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={onSort}
|
||||
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
await user.click(sortButton);
|
||||
expect(onSort).toHaveBeenCalledWith({
|
||||
columnName: 'sortable',
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSort with null when clicking desc-sorted column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSort = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={onSort}
|
||||
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
await user.click(sortButton);
|
||||
expect(onSort).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('shows ascending indicator when orderBy matches column with asc', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={jest.fn()}
|
||||
orderBy={{ columnName: 'sortable', order: 'asc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
expect(sortButton).toHaveAttribute('data-sort', 'ascending');
|
||||
});
|
||||
|
||||
it('shows descending indicator when orderBy matches column with desc', () => {
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={sortableCol}
|
||||
header={sortableHeader}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
onSort={jest.fn()}
|
||||
orderBy={{ columnName: 'sortable', order: 'desc' }}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
const sortButton = screen.getByTitle('Sortable');
|
||||
expect(sortButton).toHaveAttribute('data-sort', 'descending');
|
||||
});
|
||||
|
||||
it('does not show sort button when enableSort is false', () => {
|
||||
const nonSortableCol = col('nonsort', {
|
||||
enableSort: false,
|
||||
header: 'Nonsort',
|
||||
});
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={nonSortableCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
// When enableSort is false, the header text is rendered as a span, not a button
|
||||
// The title 'Nonsort' exists on the span, not on a button
|
||||
const titleElement = screen.getByTitle('Nonsort');
|
||||
expect(titleElement.tagName.toLowerCase()).not.toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resizing', () => {
|
||||
it('shows resize handle when enableResize is not false', () => {
|
||||
const resizableCol = col('resizable', { enableResize: true });
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={resizableCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
// Resize handle has title "Drag to resize column"
|
||||
expect(screen.getByTitle('Drag to resize column')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides resize handle when enableResize is false', () => {
|
||||
const nonResizableCol = col('noresize', { enableResize: false });
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={nonResizableCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.queryByTitle('Drag to resize column')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('column movement', () => {
|
||||
it('does not show grip when enableMove is false', () => {
|
||||
const noMoveCol = col('nomove', { enableMove: false });
|
||||
render(
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TanStackHeaderRow
|
||||
column={noMoveCol}
|
||||
header={header}
|
||||
isDarkMode={false}
|
||||
hasSingleColumn={false}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /drag/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { fireEvent, 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}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</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,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
|
||||
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,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</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>,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</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>,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="expansion"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('new tab click', () => {
|
||||
it('calls onRowClickNewTab on ctrl+click', () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRowClickNewTab on meta+click (cmd)', () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onRowClick when modifier key is pressed', () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
import { renderTanStackTable } from './testUtils';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('../TanStackTable.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
tanStackTable: 'tanStackTable',
|
||||
tableRow: 'tableRow',
|
||||
tableRowActive: 'tableRowActive',
|
||||
tableRowExpansion: 'tableRowExpansion',
|
||||
tableCell: 'tableCell',
|
||||
tableCellExpansion: 'tableCellExpansion',
|
||||
tableHeaderCell: 'tableHeaderCell',
|
||||
tableCellText: 'tableCellText',
|
||||
tableViewRowActions: 'tableViewRowActions',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('TanStackTableView Integration', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all data rows', async () => {
|
||||
renderTanStackTable({});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders column headers', async () => {
|
||||
renderTanStackTable({});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ID')).toBeInTheDocument();
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty state when data is empty and not loading', async () => {
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: false },
|
||||
});
|
||||
// Table should still render but with no data rows
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders table structure when loading with no previous data', async () => {
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: true },
|
||||
});
|
||||
// Table should render with skeleton rows
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('keeps table mounted when loading with no data', () => {
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: true },
|
||||
});
|
||||
// Table should still be in the DOM for skeleton rows
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading spinner for infinite scroll when loading', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: true, onEndReached: jest.fn() },
|
||||
});
|
||||
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loading spinner for infinite scroll when not loading', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: false, onEndReached: jest.fn() },
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('tanstack-infinite-loader'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loading spinner when not in infinite scroll mode', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: true },
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('tanstack-infinite-loader'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
it('renders pagination when pagination prop is provided', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
},
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Look for pagination navigation or page number text
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates page when clicking page number', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
|
||||
enableQueryParams: true,
|
||||
},
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find page 2 button/link within pagination navigation
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2 = Array.from(nav.querySelectorAll('button')).find(
|
||||
(btn) => btn.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2) {
|
||||
throw new Error('Page 2 button not found in pagination');
|
||||
}
|
||||
await user.click(page2);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render pagination in infinite scroll mode', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
onEndReached: jest.fn(), // This enables infinite scroll mode
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Pagination should not be visible in infinite scroll mode
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders prefixPaginationContent before pagination', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
prefixPaginationContent: <span data-testid="prefix-content">Prefix</span>,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('prefix-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders suffixPaginationContent after pagination', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
suffixPaginationContent: <span data-testid="suffix-content">Suffix</span>,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
it('updates orderBy URL param when clicking sortable header', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: { enableQueryParams: true },
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the sortable column header's sort button (ID column has enableSort: true)
|
||||
const sortButton = screen.getByTitle('ID');
|
||||
await user.click(sortButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('order_by'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
const parsed = JSON.parse(lastOrderBy!);
|
||||
expect(parsed.columnName).toBe('id');
|
||||
expect(parsed.order).toBe('asc');
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles sort order on subsequent clicks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
renderTanStackTable({
|
||||
props: { enableQueryParams: true },
|
||||
onUrlUpdate,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sortButton = screen.getByTitle('ID');
|
||||
|
||||
// First click - asc
|
||||
await user.click(sortButton);
|
||||
// Second click - desc
|
||||
await user.click(sortButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('order_by'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (lastOrderBy) {
|
||||
const parsed = JSON.parse(lastOrderBy);
|
||||
expect(parsed.order).toBe('desc');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('row selection', () => {
|
||||
it('calls onRowClick with row data and itemKey', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active class when isRowActive returns true', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the row containing Item 1 and check for active class
|
||||
const cell = screen.getByText('Item 1');
|
||||
const row = cell.closest('tr');
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Item 1'), { ctrlKey: true });
|
||||
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
'1',
|
||||
);
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens in new tab on meta+click', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const onRowClickNewTab = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
getRowKey: (row) => row.id,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Item 1'), { metaKey: true });
|
||||
|
||||
expect(onRowClickNewTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
'1',
|
||||
);
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('row expansion', () => {
|
||||
it('renders expanded content below the row when expanded', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
renderExpandedRow: (row) => (
|
||||
<div data-testid="expanded-content">Expanded: {row.name}</div>
|
||||
),
|
||||
getRowCanExpand: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click expand button (if available in the row)
|
||||
// The expansion is controlled by TanStack Table's expanded state
|
||||
// For now, just verify the renderExpandedRow prop is wired correctly
|
||||
// by checking the table renders without errors
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('infinite scroll', () => {
|
||||
it('calls onEndReached when provided', async () => {
|
||||
const onEndReached = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onEndReached,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Virtuoso will call onEndReached based on scroll position
|
||||
// In mock context, we verify the prop is wired correctly
|
||||
expect(onEndReached).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows loading spinner at bottom when loading in infinite scroll mode', () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
isLoading: true,
|
||||
onEndReached: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides pagination in infinite scroll mode', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
pagination: { total: 100 },
|
||||
onEndReached: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// When onEndReached is provided, pagination should not render
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { TooltipProvider } from '@signozhq/ui';
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||
|
||||
import TanStackTable from '../index';
|
||||
import type { TableColumnDef, TanStackTableProps } from '../types';
|
||||
|
||||
// NOTE: Test files importing this utility must add this mock at the top of their file:
|
||||
// jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
|
||||
// Default test data types
|
||||
export type TestRow = { id: string; name: string; value: number };
|
||||
|
||||
export const defaultColumns: TableColumnDef<TestRow>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
enableSort: true,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: 'Value',
|
||||
accessorKey: 'value',
|
||||
enableSort: true,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultData: TestRow[] = [
|
||||
{ id: '1', name: 'Item 1', value: 100 },
|
||||
{ id: '2', name: 'Item 2', value: 200 },
|
||||
{ id: '3', name: 'Item 3', value: 300 },
|
||||
];
|
||||
|
||||
export type RenderTanStackTableOptions<T> = {
|
||||
props?: Partial<TanStackTableProps<T>>;
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
};
|
||||
|
||||
export function renderTanStackTable<T = TestRow>(
|
||||
options: RenderTanStackTableOptions<T> = {},
|
||||
): RenderResult {
|
||||
const { props = {}, queryParams, onUrlUpdate } = options;
|
||||
|
||||
const mergedProps = {
|
||||
data: (defaultData as unknown) as T[],
|
||||
columns: (defaultColumns as unknown) as TableColumnDef<T>[],
|
||||
...props,
|
||||
} as TanStackTableProps<T>;
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<TanStackTable<T> {...mergedProps} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to wrap any component with test providers (for unit tests)
|
||||
export function renderWithProviders(
|
||||
ui: ReactNode,
|
||||
options: {
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
} = {},
|
||||
): RenderResult {
|
||||
const { queryParams, onUrlUpdate } = options;
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 500, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>{ui}</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { TableColumnDef } from '../types';
|
||||
import { useColumnState } from '../useColumnState';
|
||||
import { useColumnStore } from '../useColumnStore';
|
||||
|
||||
const TEST_KEY = 'test-state';
|
||||
|
||||
type TestRow = { id: string; name: string };
|
||||
|
||||
const col = (
|
||||
id: string,
|
||||
overrides: Partial<TableColumnDef<TestRow>> = {},
|
||||
): TableColumnDef<TestRow> => ({
|
||||
id,
|
||||
header: id,
|
||||
cell: (): null => null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useColumnState', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('initializes store from column defaults on mount', () => {
|
||||
const columns = [
|
||||
col('a', { defaultVisibility: true }),
|
||||
col('b', { defaultVisibility: false }),
|
||||
col('c'),
|
||||
];
|
||||
|
||||
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('does not initialize without storageKey', () => {
|
||||
const columns = [col('a', { defaultVisibility: false })];
|
||||
|
||||
renderHook(() => useColumnState({ columns }));
|
||||
|
||||
expect(useColumnStore.getState().tables[TEST_KEY]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('columnVisibility', () => {
|
||||
it('returns visibility state from hidden columns', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ b: false });
|
||||
});
|
||||
|
||||
it('applies visibilityBehavior for grouped state', () => {
|
||||
const columns = [
|
||||
col('ungrouped', { visibilityBehavior: 'hidden-on-expand' }),
|
||||
col('grouped', { visibilityBehavior: 'hidden-on-collapse' }),
|
||||
col('always'),
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
// Not grouped
|
||||
const { result: notGrouped } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
|
||||
);
|
||||
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
|
||||
|
||||
// Grouped
|
||||
const { result: grouped } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
||||
);
|
||||
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
|
||||
});
|
||||
|
||||
it('combines store hidden + visibilityBehavior', () => {
|
||||
const columns = [
|
||||
col('a', { visibilityBehavior: 'hidden-on-expand' }),
|
||||
col('b'),
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortedColumns', () => {
|
||||
it('returns columns in original order when no order set', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns columns sorted by stored order', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps pinned columns at the start', () => {
|
||||
const columns = [col('a'), col('pinned', { pin: 'left' }), col('b')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'pinned',
|
||||
'b',
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('hideColumn hides a column', () => {
|
||||
const columns = [col('a'), col('b')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.hideColumn('a');
|
||||
});
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
||||
});
|
||||
|
||||
it('showColumn shows a column', () => {
|
||||
const columns = [col('a', { defaultVisibility: false })];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({ a: false });
|
||||
|
||||
act(() => {
|
||||
result.current.showColumn('a');
|
||||
});
|
||||
|
||||
expect(result.current.columnVisibility).toEqual({});
|
||||
});
|
||||
|
||||
it('setColumnSizing updates sizing', () => {
|
||||
const columns = [col('a')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setColumnSizing({ a: 200 });
|
||||
});
|
||||
|
||||
expect(result.current.columnSizing).toEqual({ a: 200 });
|
||||
});
|
||||
|
||||
it('setColumnOrder updates order from column array', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({ storageKey: TEST_KEY, columns }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
|
||||
});
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useColumnOrder,
|
||||
useColumnSizing,
|
||||
useColumnStore,
|
||||
useHiddenColumnIds,
|
||||
} from '../useColumnStore';
|
||||
|
||||
const TEST_KEY = 'test-table';
|
||||
|
||||
describe('useColumnStore', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.getState().tables = {};
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('initializeFromDefaults', () => {
|
||||
it('initializes hidden columns from defaultVisibility: false', () => {
|
||||
const columns = [
|
||||
{ id: 'a', defaultVisibility: true },
|
||||
{ id: 'b', defaultVisibility: false },
|
||||
{ id: 'c' }, // defaults to visible
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['b']);
|
||||
expect(state.columnOrder).toEqual([]);
|
||||
expect(state.columnSizing).toEqual({});
|
||||
});
|
||||
|
||||
it('does not reinitialize if already exists', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.initializeFromDefaults(TEST_KEY, [
|
||||
{ id: 'a', defaultVisibility: false },
|
||||
] as any);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'x');
|
||||
useColumnStore
|
||||
.getState()
|
||||
.initializeFromDefaults(TEST_KEY, [
|
||||
{ id: 'b', defaultVisibility: false },
|
||||
] as any);
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toContain('a');
|
||||
expect(state.hiddenColumnIds).toContain('x');
|
||||
expect(state.hiddenColumnIds).not.toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideColumn / showColumn / toggleColumn', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('hideColumn adds to hiddenColumnIds', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
|
||||
'col1',
|
||||
);
|
||||
});
|
||||
|
||||
it('hideColumn is idempotent', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(
|
||||
useColumnStore
|
||||
.getState()
|
||||
.tables[TEST_KEY].hiddenColumnIds.filter((id) => id === 'col1'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('showColumn removes from hiddenColumnIds', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().showColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(
|
||||
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
|
||||
).not.toContain('col1');
|
||||
});
|
||||
|
||||
it('toggleColumn toggles visibility', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
|
||||
'col1',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
|
||||
});
|
||||
expect(
|
||||
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
|
||||
).not.toContain('col1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setColumnSizing', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates column sizing', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
|
||||
col1: 200,
|
||||
col2: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setColumnOrder', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates column order', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
|
||||
});
|
||||
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
|
||||
'col2',
|
||||
'col1',
|
||||
'col3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToDefaults', () => {
|
||||
it('resets to column defaults', () => {
|
||||
const columns = [
|
||||
{ id: 'a', defaultVisibility: false },
|
||||
{ id: 'b', defaultVisibility: true },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
|
||||
useColumnStore.getState().showColumn(TEST_KEY, 'a');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
|
||||
useColumnStore.getState().setColumnSizing(TEST_KEY, { a: 100 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().resetToDefaults(TEST_KEY, columns as any);
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['a']);
|
||||
expect(state.columnOrder).toEqual([]);
|
||||
expect(state.columnSizing).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupStaleHiddenColumns', () => {
|
||||
it('removes hidden column IDs that are not in validColumnIds', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col3');
|
||||
});
|
||||
|
||||
// Only col1 and col3 are valid now
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col3']));
|
||||
});
|
||||
|
||||
const state = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
|
||||
expect(state.hiddenColumnIds).not.toContain('col2');
|
||||
});
|
||||
|
||||
it('does nothing when all hidden columns are valid', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
|
||||
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
|
||||
});
|
||||
|
||||
const stateBefore = useColumnStore.getState().tables[TEST_KEY];
|
||||
const hiddenBefore = [...stateBefore.hiddenColumnIds];
|
||||
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col2', 'col3']));
|
||||
});
|
||||
|
||||
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
|
||||
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
|
||||
});
|
||||
|
||||
it('does nothing for unknown storage key', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns('unknown-key', new Set(['col1']));
|
||||
});
|
||||
|
||||
// Should not throw or create state
|
||||
expect(useColumnStore.getState().tables['unknown-key']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selector hooks', () => {
|
||||
it('useHiddenColumnIds returns hidden columns', () => {
|
||||
act(() => {
|
||||
useColumnStore
|
||||
.getState()
|
||||
.initializeFromDefaults(TEST_KEY, [
|
||||
{ id: 'a', defaultVisibility: false },
|
||||
] as any);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
||||
expect(result.current).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
|
||||
localStorage.setItem(
|
||||
'@signoz/table-columns/test-table',
|
||||
JSON.stringify({
|
||||
hiddenColumnIds: ['persisted'],
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useHiddenColumnIds(TEST_KEY));
|
||||
const firstSnapshot = result.current;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current).toBe(firstSnapshot);
|
||||
});
|
||||
|
||||
it('useColumnSizing returns sizing', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().setColumnSizing(TEST_KEY, { col1: 150 });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
|
||||
expect(result.current).toEqual({ col1: 150 });
|
||||
});
|
||||
|
||||
it('useColumnOrder returns order', () => {
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
|
||||
expect(result.current).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('returns empty defaults for unknown storageKey', () => {
|
||||
const { result: hidden } = renderHook(() => useHiddenColumnIds('unknown'));
|
||||
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
|
||||
const { result: order } = renderHook(() => useColumnOrder('unknown'));
|
||||
|
||||
expect(hidden.current).toEqual([]);
|
||||
expect(sizing.current).toEqual({});
|
||||
expect(order.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
|
||||
import { useTableParams } from '../useTableParams';
|
||||
|
||||
function createNuqsWrapper(
|
||||
queryParams?: Record<string, string>,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function NuqsWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useTableParams (local mode — enableQueryParams not set)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns default page=1 and limit=50', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
expect(result.current.page).toBe(1);
|
||||
expect(result.current.limit).toBe(50);
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
});
|
||||
|
||||
it('respects custom defaults', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(
|
||||
() => useTableParams(undefined, { page: 2, limit: 25 }),
|
||||
{ wrapper },
|
||||
);
|
||||
expect(result.current.page).toBe(2);
|
||||
expect(result.current.limit).toBe(25);
|
||||
});
|
||||
|
||||
it('setPage updates page', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setPage(3);
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('setLimit updates limit', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setLimit(100);
|
||||
});
|
||||
expect(result.current.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('setOrderBy updates orderBy', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses nuqs state when enableQueryParams=true', () => {
|
||||
const wrapper = createNuqsWrapper();
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.page).toBe(1);
|
||||
act(() => {
|
||||
result.current.setPage(5);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(5);
|
||||
});
|
||||
|
||||
it('uses prefixed keys when enableQueryParams is a string', () => {
|
||||
const wrapper = createNuqsWrapper({ pods_page: '2' });
|
||||
const { result } = renderHook(() => useTableParams('pods', { page: 2 }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.page).toBe(2);
|
||||
act(() => {
|
||||
result.current.setPage(4);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(result.current.page).toBe(4);
|
||||
});
|
||||
|
||||
it('local state is ignored when enableQueryParams is set', () => {
|
||||
const localWrapper = createNuqsWrapper();
|
||||
const urlWrapper = createNuqsWrapper();
|
||||
const { result: local } = renderHook(() => useTableParams(), {
|
||||
wrapper: localWrapper,
|
||||
});
|
||||
const { result: url } = renderHook(() => useTableParams(true), {
|
||||
wrapper: urlWrapper,
|
||||
});
|
||||
act(() => {
|
||||
local.current.setPage(99);
|
||||
});
|
||||
// URL mode hook in a separate wrapper should still have its own state
|
||||
expect(url.current.page).toBe(1);
|
||||
});
|
||||
|
||||
it('reads initial page from URL params', () => {
|
||||
const wrapper = createNuqsWrapper({ page: '3' });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('reads initial orderBy from URL params', () => {
|
||||
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
|
||||
const wrapper = createNuqsWrapper({ order_by: orderBy });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
|
||||
});
|
||||
|
||||
it('updates URL when setPage is called', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(5);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
const lastPage = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastPage).toBe('5');
|
||||
});
|
||||
|
||||
it('updates URL when setOrderBy is called', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'value', order: 'asc' });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
const lastOrderBy = onUrlUpdate.mock.calls
|
||||
.map((call) => call[0].searchParams.get('order_by'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
expect(JSON.parse(lastOrderBy!)).toEqual({
|
||||
columnName: 'value',
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom param names from config object', () => {
|
||||
const config = {
|
||||
page: 'listPage',
|
||||
limit: 'listLimit',
|
||||
orderBy: 'listOrderBy',
|
||||
expanded: 'listExpanded',
|
||||
};
|
||||
const wrapper = createNuqsWrapper({ listPage: '3' });
|
||||
const { result } = renderHook(() => useTableParams(config, { page: 3 }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('manages expanded state for row expansion', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded({ 'row-1': true });
|
||||
});
|
||||
|
||||
expect(result.current.expanded).toEqual({ 'row-1': true });
|
||||
});
|
||||
|
||||
it('toggles sort order correctly: null → asc → desc → null', () => {
|
||||
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const wrapper = createNuqsWrapper({}, onUrlUpdate);
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
|
||||
// Initial state
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
|
||||
// First click: null → asc
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
|
||||
|
||||
// Second click: asc → desc
|
||||
act(() => {
|
||||
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
|
||||
});
|
||||
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
|
||||
|
||||
// Third click: desc → null
|
||||
act(() => {
|
||||
result.current.setOrderBy(null);
|
||||
});
|
||||
expect(result.current.orderBy).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { TanStackTableBase } from './TanStackTable';
|
||||
import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
|
||||
* optional drag-to-reorder headers, expandable rows, and pagination or infinite scroll.
|
||||
*
|
||||
* @example Minimal usage
|
||||
* ```tsx
|
||||
* import TanStackTable from 'components/TanStackTableView';
|
||||
* import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
*
|
||||
* 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, sortable
|
||||
* ```tsx
|
||||
* const columns: TableColumnDef<Row>[] = [
|
||||
* {
|
||||
* id: 'id',
|
||||
* header: 'ID',
|
||||
* accessorKey: 'id',
|
||||
* pin: 'left',
|
||||
* width: { min: 80, default: 120 },
|
||||
* enableSort: true,
|
||||
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
||||
* },
|
||||
* {
|
||||
* id: 'computed',
|
||||
* header: () => <span>Computed</span>,
|
||||
* accessorFn: (row) => row.first + row.last,
|
||||
* enableMove: false,
|
||||
* enableRemove: false,
|
||||
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
|
||||
* },
|
||||
* ];
|
||||
* ```
|
||||
*
|
||||
* @example Column state persistence with store (recommended)
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* columnStorageKey="my-table-columns"
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Pagination with query params. Use `enableQueryParams` object to customize param names.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={pageRows}
|
||||
* columns={columns}
|
||||
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
|
||||
* enableQueryParams={{
|
||||
* page: 'listPage',
|
||||
* limit: 'listPageSize',
|
||||
* orderBy: 'orderBy',
|
||||
* expanded: 'listExpanded',
|
||||
* }}
|
||||
* prefixPaginationContent={<span>Custom prefix</span>}
|
||||
* suffixPaginationContent={<span>Custom suffix</span>}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Infinite scroll — use `onEndReached` (pagination UI is hidden when set).
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={accumulatedRows}
|
||||
* columns={columns}
|
||||
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
|
||||
* isLoading={isFetching}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Loading state and typography for plain string/number cells
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* isLoading={isFetching}
|
||||
* skeletonRowCount={15}
|
||||
* cellTypographySize="small"
|
||||
* plainTextCellLineClamp={2}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Row styling, selection, and actions. `onRowClick` receives `(row, itemKey)`.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.id}
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* 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. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.id}
|
||||
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
||||
* <pre>{JSON.stringify({ rowKey, groupMeta, raw: row.raw }, null, 2)}</pre>
|
||||
* )}
|
||||
* getRowCanExpand={(row) => Boolean(row.raw)}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Grouped rows — use `groupBy` + `getGroupKey` for group-aware key generation.
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* getRowKey={(row) => row.id}
|
||||
* groupBy={[{ key: 'namespace' }, { key: 'cluster' }]}
|
||||
* getGroupKey={(row) => row.meta ?? {}}
|
||||
* renderExpandedRow={(row, rowKey, groupMeta) => (
|
||||
* <ExpandedDetails groupMeta={groupMeta} />
|
||||
* )}
|
||||
* getRowCanExpand={() => true}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
|
||||
* ```tsx
|
||||
* import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||
*
|
||||
* 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 and testing
|
||||
* ```tsx
|
||||
* <TanStackTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* className="my-table-wrapper"
|
||||
* testId="logs-table"
|
||||
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
});
|
||||
|
||||
export default TanStackTable;
|
||||
@@ -1,191 +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 = { columnName: string; order: 'asc' | 'desc' };
|
||||
|
||||
/** 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, TValue> = {
|
||||
row: TData;
|
||||
value: TValue;
|
||||
isActive: boolean;
|
||||
rowIndex: number;
|
||||
isExpanded: boolean;
|
||||
canExpand: boolean;
|
||||
toggleExpanded: () => void;
|
||||
/** Business/selection key for the row */
|
||||
itemKey: string;
|
||||
/** Group metadata when row is part of a grouped view */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type RowKeyData = {
|
||||
/** Final unique key (with duplicate suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Business/selection key */
|
||||
itemKey: string;
|
||||
/** Group metadata */
|
||||
groupMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TableColumnDef<
|
||||
TData,
|
||||
TKey extends keyof TData = any,
|
||||
TValue = TData[TKey]
|
||||
> = {
|
||||
id: string;
|
||||
header: string | (() => ReactNode);
|
||||
cell: (context: TableCellContext<TData, TValue>) => ReactNode;
|
||||
accessorKey?: TKey;
|
||||
accessorFn?: (row: TData) => TValue;
|
||||
pin?: 'left' | 'right';
|
||||
enableMove?: boolean;
|
||||
enableResize?: boolean;
|
||||
enableRemove?: boolean;
|
||||
enableSort?: boolean;
|
||||
/** Default visibility when no persisted state exists. Default: true */
|
||||
defaultVisibility?: boolean;
|
||||
/** Whether user can hide this column. Default: true */
|
||||
canBeHidden?: boolean;
|
||||
/**
|
||||
* Visibility behavior for grouped views:
|
||||
* - 'hidden-on-expand': Hide when rows are expanded (grouped view)
|
||||
* - 'hidden-on-collapse': Hide when rows are collapsed (ungrouped view)
|
||||
* - 'always-visible': Always show regardless of grouping
|
||||
* Default: 'always-visible'
|
||||
*/
|
||||
visibilityBehavior?:
|
||||
| 'hidden-on-expand'
|
||||
| 'hidden-on-collapse'
|
||||
| 'always-visible';
|
||||
width?: {
|
||||
fixed?: number | string;
|
||||
min?: number | string;
|
||||
default?: number | string;
|
||||
max?: number | string;
|
||||
};
|
||||
};
|
||||
|
||||
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, itemKey: string) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
) => ReactNode;
|
||||
/** Get key data for a row by index */
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined;
|
||||
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;
|
||||
/** Whether there's only one non-pinned column that can be removed */
|
||||
hasSingleColumn: boolean;
|
||||
/** Column order key for memo invalidation on reorder */
|
||||
columnOrderKey: string;
|
||||
/** Column visibility key for memo invalidation on visibility change */
|
||||
columnVisibilityKey: string;
|
||||
};
|
||||
|
||||
export type PaginationProps = {
|
||||
total: number;
|
||||
defaultPage?: number;
|
||||
defaultLimit?: number;
|
||||
};
|
||||
|
||||
export type TanstackTableQueryParamsConfig = {
|
||||
page: string;
|
||||
limit: string;
|
||||
orderBy: string;
|
||||
expanded: string;
|
||||
};
|
||||
|
||||
export type TanStackTableProps<TData> = {
|
||||
data: TData[];
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
||||
columnStorageKey?: string;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
|
||||
/** Called when a column is removed via the header menu. Use this to sync with external column preferences. */
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
isLoading?: boolean;
|
||||
/** Number of skeleton rows to show when loading with no data. Default: 10 */
|
||||
skeletonRowCount?: number;
|
||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
|
||||
pagination?: PaginationProps;
|
||||
onEndReached?: (index: number) => void;
|
||||
/** Function to get the unique key for a row (before duplicate handling).
|
||||
* When set, enables automatic duplicate key detection and group-aware key composition. */
|
||||
getRowKey?: (row: TData) => string;
|
||||
/** Function to get the business/selection key. Defaults to getRowKey result. */
|
||||
getItemKey?: (row: TData) => string;
|
||||
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
|
||||
groupBy?: Array<{ key: string }>;
|
||||
/** Extract group metadata from a row. Required when groupBy is set. */
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: string) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: string) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
) => 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'>;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
/** Content rendered before the pagination controls */
|
||||
prefixPaginationContent?: ReactNode;
|
||||
/** Content rendered after the pagination controls */
|
||||
suffixPaginationContent?: ReactNode;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
|
||||
export interface UseColumnDndOptions<TData> {
|
||||
columns: TableColumnDef<TData>[];
|
||||
onColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
|
||||
}
|
||||
|
||||
export interface UseColumnDndResult {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
columnIds: string[];
|
||||
handleDragEnd: (event: DragEndEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up drag-and-drop for column reordering.
|
||||
*/
|
||||
export function useColumnDnd<TData>({
|
||||
columns,
|
||||
onColumnOrderChange,
|
||||
}: UseColumnDndOptions<TData>): UseColumnDndResult {
|
||||
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) {
|
||||
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],
|
||||
);
|
||||
|
||||
return { sensors, columnIds, handleDragEnd };
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { SetStateAction } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { ColumnSizingState } from '@tanstack/react-table';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
|
||||
export interface UseColumnHandlersOptions<TData> {
|
||||
/** Storage key for persisting column state (enables store mode) */
|
||||
columnStorageKey?: string;
|
||||
effectiveSizing: ColumnSizingState;
|
||||
storeSetSizing: (sizing: ColumnSizingState) => void;
|
||||
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
|
||||
hideColumn: (columnId: string) => void;
|
||||
onColumnSizingChange?: (sizing: ColumnSizingState) => void;
|
||||
onColumnOrderChange?: (columns: TableColumnDef<TData>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
}
|
||||
|
||||
export interface UseColumnHandlersResult<TData> {
|
||||
handleColumnSizingChange: (updater: SetStateAction<ColumnSizingState>) => void;
|
||||
handleColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
|
||||
handleRemoveColumn: (columnId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handlers for column state changes that delegate to either
|
||||
* the store (when columnStorageKey is provided) or prop callbacks.
|
||||
*/
|
||||
export function useColumnHandlers<TData>({
|
||||
columnStorageKey,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
hideColumn,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
}: UseColumnHandlersOptions<TData>): UseColumnHandlersResult<TData> {
|
||||
const handleColumnSizingChange = useCallback(
|
||||
(updater: SetStateAction<ColumnSizingState>) => {
|
||||
const next =
|
||||
typeof updater === 'function' ? updater(effectiveSizing) : updater;
|
||||
if (columnStorageKey) {
|
||||
storeSetSizing(next);
|
||||
}
|
||||
onColumnSizingChange?.(next);
|
||||
},
|
||||
[columnStorageKey, effectiveSizing, storeSetSizing, onColumnSizingChange],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TData>[]) => {
|
||||
if (columnStorageKey) {
|
||||
storeSetOrder(cols);
|
||||
}
|
||||
onColumnOrderChange?.(cols);
|
||||
},
|
||||
[columnStorageKey, storeSetOrder, onColumnOrderChange],
|
||||
);
|
||||
|
||||
const handleRemoveColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (columnStorageKey) {
|
||||
hideColumn(columnId);
|
||||
}
|
||||
onColumnRemove?.(columnId);
|
||||
},
|
||||
[columnStorageKey, hideColumn, onColumnRemove],
|
||||
);
|
||||
|
||||
return {
|
||||
handleColumnSizingChange,
|
||||
handleColumnOrderChange,
|
||||
handleRemoveColumn,
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ColumnSizingState, VisibilityState } from '@tanstack/react-table';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
import {
|
||||
cleanupStaleHiddenColumns as storeCleanupStaleHiddenColumns,
|
||||
hideColumn as storeHideColumn,
|
||||
initializeFromDefaults as storeInitializeFromDefaults,
|
||||
resetToDefaults as storeResetToDefaults,
|
||||
setColumnOrder as storeSetColumnOrder,
|
||||
setColumnSizing as storeSetColumnSizing,
|
||||
showColumn as storeShowColumn,
|
||||
toggleColumn as storeToggleColumn,
|
||||
useColumnOrder as useStoreOrder,
|
||||
useColumnSizing as useStoreSizing,
|
||||
useHiddenColumnIds,
|
||||
} from './useColumnStore';
|
||||
|
||||
type UseColumnStateOptions<TData> = {
|
||||
storageKey?: string;
|
||||
columns: TableColumnDef<TData>[];
|
||||
isGrouped?: boolean;
|
||||
};
|
||||
|
||||
type UseColumnStateResult<TData> = {
|
||||
columnVisibility: VisibilityState;
|
||||
columnSizing: ColumnSizingState;
|
||||
/** Columns sorted by persisted order (pinned first) */
|
||||
sortedColumns: TableColumnDef<TData>[];
|
||||
hiddenColumnIds: string[];
|
||||
hideColumn: (columnId: string) => void;
|
||||
showColumn: (columnId: string) => void;
|
||||
toggleColumn: (columnId: string) => void;
|
||||
setColumnSizing: (sizing: ColumnSizingState) => void;
|
||||
setColumnOrder: (columns: TableColumnDef<TData>[]) => void;
|
||||
resetToDefaults: () => void;
|
||||
};
|
||||
|
||||
export function useColumnState<TData>({
|
||||
storageKey,
|
||||
columns,
|
||||
isGrouped = false,
|
||||
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
storeInitializeFromDefaults(storageKey, columns);
|
||||
}
|
||||
// Only run on mount, not when columns change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storageKey]);
|
||||
|
||||
const rawHiddenColumnIds = useHiddenColumnIds(storageKey ?? '');
|
||||
|
||||
useEffect(
|
||||
function cleanupHiddenColumnIdsNoLongerInDefinitions(): void {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validColumnIds = new Set(columns.map((c) => c.id));
|
||||
storeCleanupStaleHiddenColumns(storageKey, validColumnIds);
|
||||
},
|
||||
[storageKey, columns],
|
||||
);
|
||||
|
||||
const columnSizing = useStoreSizing(storageKey ?? '');
|
||||
const prevColumnIdsRef = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(
|
||||
function autoShowNewlyAddedColumns(): void {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(columns.map((c) => c.id));
|
||||
|
||||
// Skip first render - just record the initial columns
|
||||
if (prevColumnIdsRef.current === null) {
|
||||
prevColumnIdsRef.current = currentIds;
|
||||
return;
|
||||
}
|
||||
|
||||
const prevIds = prevColumnIdsRef.current;
|
||||
|
||||
// Find columns that are new (in current but not in previous)
|
||||
for (const id of currentIds) {
|
||||
if (!prevIds.has(id) && rawHiddenColumnIds.includes(id)) {
|
||||
// Column was just added and is hidden - show it
|
||||
storeShowColumn(storageKey, id);
|
||||
}
|
||||
}
|
||||
|
||||
prevColumnIdsRef.current = currentIds;
|
||||
},
|
||||
[storageKey, columns, rawHiddenColumnIds],
|
||||
);
|
||||
|
||||
const columnOrder = useStoreOrder(storageKey ?? '');
|
||||
const columnMap = useMemo(() => new Map(columns.map((c) => [c.id, c])), [
|
||||
columns,
|
||||
]);
|
||||
|
||||
const hiddenColumnIds = useMemo(
|
||||
() =>
|
||||
rawHiddenColumnIds.filter((id) => {
|
||||
const col = columnMap.get(id);
|
||||
return col && col.canBeHidden !== false;
|
||||
}),
|
||||
[rawHiddenColumnIds, columnMap],
|
||||
);
|
||||
|
||||
const columnVisibility = useMemo((): VisibilityState => {
|
||||
const visibility: VisibilityState = {};
|
||||
|
||||
for (const id of hiddenColumnIds) {
|
||||
visibility[id] = false;
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
if (column.visibilityBehavior === 'hidden-on-expand' && isGrouped) {
|
||||
visibility[column.id] = false;
|
||||
}
|
||||
if (column.visibilityBehavior === 'hidden-on-collapse' && !isGrouped) {
|
||||
visibility[column.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return visibility;
|
||||
}, [hiddenColumnIds, columns, isGrouped]);
|
||||
|
||||
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
|
||||
if (columnOrder.length === 0) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const orderMap = new Map(columnOrder.map((id, i) => [id, i]));
|
||||
const pinned = columns.filter((c) => c.pin != null);
|
||||
const rest = columns.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];
|
||||
}, [columns, columnOrder]);
|
||||
|
||||
const hideColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
// Prevent hiding columns with canBeHidden: false
|
||||
const col = columnMap.get(columnId);
|
||||
if (col && col.canBeHidden === false) {
|
||||
return;
|
||||
}
|
||||
storeHideColumn(storageKey, columnId);
|
||||
},
|
||||
[storageKey, columnMap],
|
||||
);
|
||||
|
||||
const showColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (storageKey) {
|
||||
storeShowColumn(storageKey, columnId);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
const col = columnMap.get(columnId);
|
||||
const isCurrentlyHidden = hiddenColumnIds.includes(columnId);
|
||||
if (col && col.canBeHidden === false && !isCurrentlyHidden) {
|
||||
return;
|
||||
}
|
||||
storeToggleColumn(storageKey, columnId);
|
||||
},
|
||||
[storageKey, columnMap, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const setColumnSizing = useCallback(
|
||||
(sizing: ColumnSizingState) => {
|
||||
if (storageKey) {
|
||||
storeSetColumnSizing(storageKey, sizing);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const setColumnOrder = useCallback(
|
||||
(cols: TableColumnDef<TData>[]) => {
|
||||
if (storageKey) {
|
||||
storeSetColumnOrder(
|
||||
storageKey,
|
||||
cols.map((c) => c.id),
|
||||
);
|
||||
}
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
if (storageKey) {
|
||||
storeResetToDefaults(storageKey, columns);
|
||||
}
|
||||
}, [storageKey, columns]);
|
||||
|
||||
return {
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
sortedColumns,
|
||||
hiddenColumnIds,
|
||||
hideColumn,
|
||||
showColumn,
|
||||
toggleColumn,
|
||||
setColumnSizing,
|
||||
setColumnOrder,
|
||||
resetToDefaults,
|
||||
};
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { ColumnSizingState } from '@tanstack/react-table';
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { TableColumnDef } from './types';
|
||||
|
||||
const STORAGE_PREFIX = '@signoz/table-columns/';
|
||||
|
||||
const persistedTableCache = new Map<
|
||||
string,
|
||||
{ raw: string; parsed: ColumnState }
|
||||
>();
|
||||
|
||||
type ColumnState = {
|
||||
hiddenColumnIds: string[];
|
||||
columnOrder: string[];
|
||||
columnSizing: ColumnSizingState;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: ColumnState = {
|
||||
hiddenColumnIds: [],
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
};
|
||||
|
||||
type ColumnStoreState = {
|
||||
tables: Record<string, ColumnState>;
|
||||
hideColumn: (storageKey: string, columnId: string) => void;
|
||||
showColumn: (storageKey: string, columnId: string) => void;
|
||||
toggleColumn: (storageKey: string, columnId: string) => void;
|
||||
setColumnSizing: (storageKey: string, sizing: ColumnSizingState) => void;
|
||||
setColumnOrder: (storageKey: string, order: string[]) => void;
|
||||
initializeFromDefaults: <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
) => void;
|
||||
resetToDefaults: <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
) => void;
|
||||
cleanupStaleHiddenColumns: (
|
||||
storageKey: string,
|
||||
validColumnIds: Set<string>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const getDefaultHiddenIds = <TData>(
|
||||
columns: TableColumnDef<TData>[],
|
||||
): string[] =>
|
||||
columns.filter((c) => c.defaultVisibility === false).map((c) => c.id);
|
||||
|
||||
const getStorageKeyForTable = (tableKey: string): string =>
|
||||
`${STORAGE_PREFIX}${tableKey}`;
|
||||
|
||||
const loadTableFromStorage = (tableKey: string): ColumnState | null => {
|
||||
try {
|
||||
const raw = get(getStorageKeyForTable(tableKey));
|
||||
if (!raw) {
|
||||
persistedTableCache.delete(tableKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = persistedTableCache.get(tableKey);
|
||||
if (cached && cached.raw === raw) {
|
||||
return cached.parsed;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as ColumnState;
|
||||
persistedTableCache.set(tableKey, { raw, parsed });
|
||||
return parsed;
|
||||
} catch {
|
||||
persistedTableCache.delete(tableKey);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveTableToStorage = (tableKey: string, state: ColumnState): void => {
|
||||
try {
|
||||
const raw = JSON.stringify(state);
|
||||
set(getStorageKeyForTable(tableKey), raw);
|
||||
persistedTableCache.set(tableKey, { raw, parsed: state });
|
||||
} catch {
|
||||
// Ignore storage errors (e.g., private browsing quota exceeded)
|
||||
}
|
||||
};
|
||||
|
||||
export const useColumnStore = create<ColumnStoreState>()((set, get) => {
|
||||
return {
|
||||
tables: {},
|
||||
hideColumn: (storageKey, columnId): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
// Lazy load from storage if not in memory
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
table = persisted;
|
||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (table.hiddenColumnIds.includes(columnId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
hiddenColumnIds: [...table.hiddenColumnIds, columnId],
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
showColumn: (storageKey, columnId): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
table = persisted;
|
||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!table.hiddenColumnIds.includes(columnId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
hiddenColumnIds: table.hiddenColumnIds.filter((id) => id !== columnId),
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
toggleColumn: (storageKey, columnId): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
table = persisted;
|
||||
set({ tables: { ...state.tables, [storageKey]: table } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isHidden = table.hiddenColumnIds.includes(columnId);
|
||||
if (isHidden) {
|
||||
get().showColumn(storageKey, columnId);
|
||||
} else {
|
||||
get().hideColumn(storageKey, columnId);
|
||||
}
|
||||
},
|
||||
setColumnSizing: (storageKey, sizing): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
table = persisted ?? { ...EMPTY_STATE };
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
columnSizing: sizing,
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
setColumnOrder: (storageKey, order): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
table = persisted ?? { ...EMPTY_STATE };
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
columnOrder: order,
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
initializeFromDefaults: (storageKey, columns): void => {
|
||||
const state = get();
|
||||
|
||||
if (state.tables[storageKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (persisted) {
|
||||
set({ tables: { ...state.tables, [storageKey]: persisted } });
|
||||
return;
|
||||
}
|
||||
|
||||
const newTable: ColumnState = {
|
||||
hiddenColumnIds: getDefaultHiddenIds(columns),
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
};
|
||||
set({ tables: { ...state.tables, [storageKey]: newTable } });
|
||||
saveTableToStorage(storageKey, newTable);
|
||||
},
|
||||
|
||||
resetToDefaults: (storageKey, columns): void => {
|
||||
const newTable: ColumnState = {
|
||||
hiddenColumnIds: getDefaultHiddenIds(columns),
|
||||
columnOrder: [],
|
||||
columnSizing: {},
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: newTable } });
|
||||
saveTableToStorage(storageKey, newTable);
|
||||
},
|
||||
|
||||
cleanupStaleHiddenColumns: (storageKey, validColumnIds): void => {
|
||||
const state = get();
|
||||
let table = state.tables[storageKey];
|
||||
|
||||
if (!table) {
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
table = persisted;
|
||||
}
|
||||
|
||||
const filteredHiddenIds = table.hiddenColumnIds.filter((id) =>
|
||||
validColumnIds.has(id),
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
if (filteredHiddenIds.length === table.hiddenColumnIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTable = {
|
||||
...table,
|
||||
hiddenColumnIds: filteredHiddenIds,
|
||||
};
|
||||
set({ tables: { ...get().tables, [storageKey]: nextTable } });
|
||||
saveTableToStorage(storageKey, nextTable);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stable empty references to avoid `Object.is` false-negatives when a key
|
||||
// does not exist yet (returning a new `[]` / `{}` on every selector call
|
||||
// would trigger React's useSyncExternalStore tearing detection).
|
||||
const EMPTY_ARRAY: string[] = [];
|
||||
const EMPTY_SIZING: ColumnSizingState = {};
|
||||
|
||||
export const useHiddenColumnIds = (storageKey: string): string[] =>
|
||||
useColumnStore((s) => {
|
||||
const table = s.tables[storageKey];
|
||||
if (table) {
|
||||
return table.hiddenColumnIds;
|
||||
}
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
return persisted?.hiddenColumnIds ?? EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
export const useColumnSizing = (storageKey: string): ColumnSizingState =>
|
||||
useColumnStore((s) => {
|
||||
const table = s.tables[storageKey];
|
||||
if (table) {
|
||||
return table.columnSizing;
|
||||
}
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
return persisted?.columnSizing ?? EMPTY_SIZING;
|
||||
});
|
||||
|
||||
export const useColumnOrder = (storageKey: string): string[] =>
|
||||
useColumnStore((s) => {
|
||||
const table = s.tables[storageKey];
|
||||
if (table) {
|
||||
return table.columnOrder;
|
||||
}
|
||||
const persisted = loadTableFromStorage(storageKey);
|
||||
return persisted?.columnOrder ?? EMPTY_ARRAY;
|
||||
});
|
||||
|
||||
export const initializeFromDefaults = <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
): void =>
|
||||
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
|
||||
|
||||
export const hideColumn = (storageKey: string, columnId: string): void =>
|
||||
useColumnStore.getState().hideColumn(storageKey, columnId);
|
||||
|
||||
export const showColumn = (storageKey: string, columnId: string): void =>
|
||||
useColumnStore.getState().showColumn(storageKey, columnId);
|
||||
|
||||
export const toggleColumn = (storageKey: string, columnId: string): void =>
|
||||
useColumnStore.getState().toggleColumn(storageKey, columnId);
|
||||
|
||||
export const setColumnSizing = (
|
||||
storageKey: string,
|
||||
sizing: ColumnSizingState,
|
||||
): void => useColumnStore.getState().setColumnSizing(storageKey, sizing);
|
||||
|
||||
export const setColumnOrder = (storageKey: string, order: string[]): void =>
|
||||
useColumnStore.getState().setColumnOrder(storageKey, order);
|
||||
|
||||
export const resetToDefaults = <TData>(
|
||||
storageKey: string,
|
||||
columns: TableColumnDef<TData>[],
|
||||
): void => useColumnStore.getState().resetToDefaults(storageKey, columns);
|
||||
|
||||
export const cleanupStaleHiddenColumns = (
|
||||
storageKey: string,
|
||||
validColumnIds: Set<string>,
|
||||
): void =>
|
||||
useColumnStore
|
||||
.getState()
|
||||
.cleanupStaleHiddenColumns(storageKey, validColumnIds);
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
export interface UseEffectiveDataOptions {
|
||||
data: unknown[];
|
||||
isLoading: boolean;
|
||||
limit?: number;
|
||||
skeletonRowCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages effective data for the table, handling loading states gracefully.
|
||||
*/
|
||||
export function useEffectiveData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
limit,
|
||||
skeletonRowCount = 10,
|
||||
}: UseEffectiveDataOptions): TData[] {
|
||||
const prevDataRef = useRef<TData[]>(data as TData[]);
|
||||
const prevDataSizeRef = useRef(data.length || limit || skeletonRowCount);
|
||||
|
||||
// Update refs when we have real data (not loading)
|
||||
if (!isLoading && data.length > 0) {
|
||||
prevDataRef.current = data as TData[];
|
||||
prevDataSizeRef.current = data.length;
|
||||
}
|
||||
|
||||
return useMemo((): TData[] => {
|
||||
if (data.length > 0) {
|
||||
return data as TData[];
|
||||
}
|
||||
if (prevDataRef.current.length > 0) {
|
||||
return prevDataRef.current;
|
||||
}
|
||||
if (isLoading) {
|
||||
const fakeCount = prevDataSizeRef.current || limit || skeletonRowCount;
|
||||
return Array.from({ length: fakeCount }, (_, i) => ({
|
||||
id: `skeleton-${i}`,
|
||||
})) as TData[];
|
||||
}
|
||||
return data as TData[];
|
||||
}, [isLoading, data, limit, skeletonRowCount]);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ExpandedState, Row } from '@tanstack/react-table';
|
||||
|
||||
import { FlatItem } from './types';
|
||||
|
||||
export interface UseFlatItemsOptions<TData> {
|
||||
tableRows: Row<TData>[];
|
||||
/** Whether row expansion is enabled, needs to be unknown since it will be a function that can be updated/modified, boolean does not work well here */
|
||||
renderExpandedRow?: unknown;
|
||||
expanded: ExpandedState;
|
||||
/** Index of the active row (for scroll-to behavior) */
|
||||
activeRowIndex?: number;
|
||||
}
|
||||
|
||||
export interface UseFlatItemsResult<TData> {
|
||||
flatItems: FlatItem<TData>[];
|
||||
/** Index of active row in flatItems (-1 if not found) */
|
||||
flatIndexForActiveRow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens table rows with their expansion rows into a single list.
|
||||
*
|
||||
* When a row is expanded, an expansion item is inserted immediately after it.
|
||||
* Also computes the flat index for the active row (used for scroll-to).
|
||||
*/
|
||||
export function useFlatItems<TData>({
|
||||
tableRows,
|
||||
renderExpandedRow,
|
||||
expanded,
|
||||
activeRowIndex,
|
||||
}: UseFlatItemsOptions<TData>): UseFlatItemsResult<TData> {
|
||||
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;
|
||||
// expanded needs to be here, otherwise the rows are not updated when you click to expand
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableRows, renderExpandedRow, expanded]);
|
||||
|
||||
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]);
|
||||
|
||||
return { flatItems, flatIndexForActiveRow };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export interface RowKeyDataItem {
|
||||
/** Final unique key for the row (with dedup suffix if needed) */
|
||||
finalKey: string;
|
||||
/** Item key for tracking (may differ from finalKey) */
|
||||
itemKey: string;
|
||||
/** Group metadata when grouped */
|
||||
groupMeta: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataOptions<TData> {
|
||||
data: TData[];
|
||||
isLoading: boolean;
|
||||
getRowKey?: (item: TData) => string;
|
||||
getItemKey?: (item: TData) => string;
|
||||
groupBy?: Array<{ key: string }>;
|
||||
getGroupKey?: (item: TData) => Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseRowKeyDataResult {
|
||||
/** Array of key data for each row, undefined if getRowKey not provided or loading */
|
||||
rowKeyData: RowKeyDataItem[] | undefined;
|
||||
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes unique row keys with duplicate handling and group prefixes.
|
||||
*/
|
||||
export function useRowKeyData<TData>({
|
||||
data,
|
||||
isLoading,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
groupBy,
|
||||
getGroupKey,
|
||||
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
|
||||
if (!getRowKey || isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyCount = new Map<string, number>();
|
||||
|
||||
return data.map(
|
||||
(item, index): RowKeyDataItem => {
|
||||
const itemIdentifier = getRowKey(item);
|
||||
const itemKey = getItemKey?.(item) ?? itemIdentifier;
|
||||
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
|
||||
|
||||
// Build rowKey with group prefix when grouped
|
||||
let rowKey: string;
|
||||
if (groupBy?.length && groupMeta) {
|
||||
const groupKeyStr = Object.values(groupMeta).join('-');
|
||||
if (groupKeyStr && itemIdentifier) {
|
||||
rowKey = `${groupKeyStr}-${itemIdentifier}`;
|
||||
} else {
|
||||
rowKey = groupKeyStr || itemIdentifier || String(index);
|
||||
}
|
||||
} else {
|
||||
rowKey = itemIdentifier || String(index);
|
||||
}
|
||||
|
||||
const count = keyCount.get(rowKey) || 0;
|
||||
keyCount.set(rowKey, count + 1);
|
||||
const finalKey = count > 0 ? `${rowKey}-${count}` : rowKey;
|
||||
|
||||
return { finalKey, itemKey, groupMeta };
|
||||
},
|
||||
);
|
||||
}, [data, getRowKey, getItemKey, groupBy, getGroupKey, isLoading]);
|
||||
|
||||
const getRowKeyData = useCallback((index: number) => rowKeyData?.[index], [
|
||||
rowKeyData,
|
||||
]);
|
||||
|
||||
return { rowKeyData, getRowKeyData };
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ExpandedState, Updater } from '@tanstack/react-table';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { SortState, TanstackTableQueryParamsConfig } 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;
|
||||
expanded?: ExpandedState;
|
||||
};
|
||||
|
||||
type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
expanded: ExpandedState;
|
||||
setPage: (p: number) => void;
|
||||
setLimit: (l: number) => void;
|
||||
setOrderBy: (s: SortState | null) => void;
|
||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||
};
|
||||
|
||||
function expandedStateToArray(state: ExpandedState): string[] {
|
||||
if (typeof state === 'boolean') {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(state)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k);
|
||||
}
|
||||
|
||||
function arrayToExpandedState(arr: string[]): ExpandedState {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const id of arr) {
|
||||
result[id] = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function useTableParams(
|
||||
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
|
||||
defaults?: Defaults,
|
||||
): TableParamsResult {
|
||||
const pageQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_page`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.page
|
||||
: 'page';
|
||||
const limitQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_limit`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.limit
|
||||
: 'limit';
|
||||
const orderByQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_order_by`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.orderBy
|
||||
: 'order_by';
|
||||
const expandedQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_expanded`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.expanded
|
||||
: 'expanded';
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
const expandedDefault = defaults?.expanded ?? {};
|
||||
const expandedDefaultArray = useMemo(
|
||||
() => expandedStateToArray(expandedDefault),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const [localPage, setLocalPage] = useState(pageDefault);
|
||||
const [localLimit, setLocalLimit] = useState(limitDefault);
|
||||
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
|
||||
orderByDefault,
|
||||
);
|
||||
const [localExpanded, setLocalExpanded] = useState<ExpandedState>(
|
||||
expandedDefault,
|
||||
);
|
||||
|
||||
const [urlPage, setUrlPage] = useQueryState(
|
||||
pageQueryParam,
|
||||
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlLimit, setUrlLimit] = useQueryState(
|
||||
limitQueryParam,
|
||||
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlOrderBy, setUrlOrderBy] = useQueryState(
|
||||
orderByQueryParam,
|
||||
parseAsJsonNoValidate<SortState | null>()
|
||||
.withDefault(orderByDefault as never)
|
||||
.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
const [urlExpandedArray, setUrlExpandedArray] = useQueryState(
|
||||
expandedQueryParam,
|
||||
parseAsJsonNoValidate<string[]>()
|
||||
.withDefault(expandedDefaultArray as never)
|
||||
.withOptions(NUQS_OPTIONS),
|
||||
);
|
||||
|
||||
// Convert URL array to ExpandedState
|
||||
const urlExpanded = useMemo(
|
||||
() => arrayToExpandedState(urlExpandedArray ?? []),
|
||||
[urlExpandedArray],
|
||||
);
|
||||
|
||||
// Keep ref for updater function access
|
||||
const urlExpandedRef = useRef(urlExpanded);
|
||||
urlExpandedRef.current = urlExpanded;
|
||||
|
||||
// Wrapper to convert ExpandedState to array when setting URL state
|
||||
// Supports both direct values and updater functions (TanStack pattern)
|
||||
const setUrlExpanded = useCallback(
|
||||
(updaterOrValue: Updater<ExpandedState>): void => {
|
||||
const newState =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(urlExpandedRef.current)
|
||||
: updaterOrValue;
|
||||
setUrlExpandedArray(expandedStateToArray(newState));
|
||||
},
|
||||
[setUrlExpandedArray],
|
||||
);
|
||||
|
||||
// Wrapper for local expanded to match TanStack's Updater pattern
|
||||
const handleSetLocalExpanded = useCallback(
|
||||
(updaterOrValue: Updater<ExpandedState>): void => {
|
||||
setLocalExpanded((prev) =>
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(prev)
|
||||
: updaterOrValue,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const isEnabledQueryParams =
|
||||
typeof enableQueryParams === 'string' ||
|
||||
typeof enableQueryParams === 'object';
|
||||
|
||||
useEffect(() => {
|
||||
if (isEnabledQueryParams) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}, [
|
||||
isEnabledQueryParams,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
setUrlPage,
|
||||
]);
|
||||
|
||||
if (enableQueryParams) {
|
||||
return {
|
||||
page: urlPage,
|
||||
limit: urlLimit,
|
||||
orderBy: urlOrderBy as SortState | null,
|
||||
expanded: urlExpanded,
|
||||
setPage: setUrlPage,
|
||||
setLimit: setUrlLimit,
|
||||
setOrderBy: setUrlOrderBy,
|
||||
setExpanded: setUrlExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
page: localPage,
|
||||
limit: localLimit,
|
||||
orderBy: localOrderBy,
|
||||
expanded: localExpanded,
|
||||
setPage: setLocalPage,
|
||||
setLimit: setLocalLimit,
|
||||
setOrderBy: setLocalOrderBy,
|
||||
setExpanded: handleSetLocalExpanded,
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { RowKeyData, TableColumnDef } from './types';
|
||||
|
||||
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
|
||||
column.id;
|
||||
|
||||
const DEFAULT_MIN_WIDTH = 192; // 12rem * 16px
|
||||
|
||||
/**
|
||||
* Parse a numeric pixel value from a number or string (e.g., 200 or "200px").
|
||||
* Returns undefined for non-pixel strings like "100%" or "10rem".
|
||||
*/
|
||||
const parsePixelValue = (
|
||||
value: number | string | undefined,
|
||||
): number | undefined => {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
// Only parse pixel values, ignore %, rem, em, etc.
|
||||
const match = /^(\d+(?:\.\d+)?)px$/.exec(value);
|
||||
return match ? parseFloat(match[1]) : undefined;
|
||||
};
|
||||
|
||||
export const getColumnWidthStyle = <TData>(
|
||||
column: TableColumnDef<TData>,
|
||||
/** Persisted width from user resizing (overrides defined width) */
|
||||
persistedWidth?: number,
|
||||
/** Last column always gets width: 100% and ignores other width settings */
|
||||
isLastColumn?: boolean,
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn) {
|
||||
return { width: '100%' };
|
||||
}
|
||||
|
||||
const { width } = column;
|
||||
if (!width) {
|
||||
return {
|
||||
width: persistedWidth ?? DEFAULT_MIN_WIDTH,
|
||||
minWidth: DEFAULT_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
if (width.fixed != null) {
|
||||
return {
|
||||
width: width.fixed,
|
||||
minWidth: width.fixed,
|
||||
maxWidth: width.fixed,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: persistedWidth ?? width.default ?? width.min,
|
||||
minWidth: width.min ?? DEFAULT_MIN_WIDTH,
|
||||
maxWidth: width.max,
|
||||
};
|
||||
};
|
||||
|
||||
const buildAccessorFn = <TData>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
): ((row: TData) => unknown) => {
|
||||
return (row: TData): unknown => {
|
||||
if (colDef.accessorFn) {
|
||||
return colDef.accessorFn(row);
|
||||
}
|
||||
if (colDef.accessorKey) {
|
||||
return (row as Record<string, unknown>)[colDef.accessorKey];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function buildTanstackColumnDef<TData>(
|
||||
colDef: TableColumnDef<TData>,
|
||||
isRowActive?: (row: TData) => boolean,
|
||||
getRowKeyData?: (index: number) => RowKeyData | undefined,
|
||||
): ColumnDef<TData> {
|
||||
const isFixed = colDef.width?.fixed != null;
|
||||
const headerFn =
|
||||
typeof colDef.header === 'function' ? colDef.header : undefined;
|
||||
|
||||
// Extract numeric size/minSize/maxSize for TanStack's resize behavior
|
||||
// This ensures TanStack's internal resize state stays in sync with CSS constraints
|
||||
let size: number | undefined;
|
||||
let minSize: number | undefined;
|
||||
let maxSize: number | undefined;
|
||||
|
||||
const fixedWidth = parsePixelValue(colDef.width?.fixed);
|
||||
if (isFixed && fixedWidth != null) {
|
||||
size = fixedWidth;
|
||||
minSize = fixedWidth;
|
||||
maxSize = fixedWidth;
|
||||
} else {
|
||||
// Match the logic in getColumnWidthStyle for initial size
|
||||
const defaultSize = parsePixelValue(colDef.width?.default);
|
||||
const minWidth = parsePixelValue(colDef.width?.min) ?? DEFAULT_MIN_WIDTH;
|
||||
size = defaultSize ?? minWidth;
|
||||
minSize = minWidth;
|
||||
maxSize = parsePixelValue(colDef.width?.max);
|
||||
}
|
||||
|
||||
return {
|
||||
id: colDef.id,
|
||||
size,
|
||||
minSize,
|
||||
maxSize,
|
||||
header:
|
||||
typeof colDef.header === 'string'
|
||||
? colDef.header
|
||||
: (): ReactNode => headerFn?.() ?? null,
|
||||
accessorFn: buildAccessorFn(colDef),
|
||||
enableResizing: colDef.enableResize !== false && !isFixed,
|
||||
enableSorting: colDef.enableSort === true,
|
||||
cell: ({ row, getValue }): ReactNode => {
|
||||
const rowData = row.original;
|
||||
const keyData = getRowKeyData?.(row.index);
|
||||
return colDef.cell({
|
||||
row: rowData,
|
||||
value: getValue() as TData[any],
|
||||
isActive: isRowActive?.(rowData) ?? false,
|
||||
rowIndex: row.index,
|
||||
isExpanded: row.getIsExpanded(),
|
||||
canExpand: row.getCanExpand(),
|
||||
toggleExpanded: (): void => {
|
||||
row.toggleExpanded();
|
||||
},
|
||||
itemKey: keyData?.itemKey ?? '',
|
||||
groupMeta: keyData?.groupMeta,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,41 +1,27 @@
|
||||
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { toast } from '@signozhq/ui';
|
||||
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 type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
|
||||
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';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
import { getAbsoluteUrl } from '@/utils/basePath';
|
||||
|
||||
import { LiveLogsListProps } from './types';
|
||||
|
||||
@@ -46,16 +32,11 @@ function LiveLogsList({
|
||||
isLoading,
|
||||
handleChangeSelectedView,
|
||||
}: LiveLogsListProps): JSX.Element {
|
||||
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const { isConnectionLoading } = useEventSource();
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
const { logs: logsPreferences } = usePreferenceContext();
|
||||
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
||||
const hasReconciledHiddenColumnsRef = useRef(false);
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
@@ -71,7 +52,7 @@ function LiveLogsList({
|
||||
[logs],
|
||||
);
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
@@ -87,65 +68,9 @@ function LiveLogsList({
|
||||
...options.selectColumns,
|
||||
]);
|
||||
|
||||
const syncedSelectedColumns = useMemo(
|
||||
() =>
|
||||
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
|
||||
[options.selectColumns, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const logsColumns = useLogsTableColumns({
|
||||
fields: selectedFields,
|
||||
fontSize: options.fontSize,
|
||||
appendTo: 'end',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasReconciledHiddenColumnsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReconciledHiddenColumnsRef.current = true;
|
||||
|
||||
if (syncedSelectedColumns.length === options.selectColumns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
logsPreferences.updateColumns(syncedSelectedColumns);
|
||||
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
|
||||
|
||||
const handleColumnRemove = useCallback(
|
||||
(columnId: string) => {
|
||||
const updatedColumns = options.selectColumns.filter(
|
||||
({ name }) => name !== columnId,
|
||||
);
|
||||
logsPreferences.updateColumns(updatedColumns);
|
||||
},
|
||||
[options.selectColumns, logsPreferences],
|
||||
);
|
||||
|
||||
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 = getAbsoluteUrl(
|
||||
`${window.location.pathname}?${urlQuery.toString()}`,
|
||||
);
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||
},
|
||||
[setCopy],
|
||||
);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs: formattedLogs,
|
||||
virtuosoRef: ref as React.RefObject<Pick<
|
||||
VirtuosoHandle,
|
||||
'scrollToIndex'
|
||||
> | null>,
|
||||
virtuosoRef: ref,
|
||||
});
|
||||
|
||||
const getItemContent = useCallback(
|
||||
@@ -231,49 +156,29 @@ function LiveLogsList({
|
||||
{formattedLogs.length !== 0 && (
|
||||
<InfinityWrapperStyled>
|
||||
{options.format === OptionFormatTypes.TABLE ? (
|
||||
<TanStackTable<ILog>
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
onColumnRemove={handleColumnRemove}
|
||||
plainTextCellLineClamp={options.maxLines}
|
||||
cellTypographySize={options.fontSize}
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ColumnDef, DataTable, Row } from '@signozhq/ui';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import { useTableView } from 'components/Logs/TableView/useTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import dayjs from 'dayjs';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import useDragColumns from 'hooks/useDragColumns';
|
||||
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
interface ColumnViewProps {
|
||||
logs: ILog[];
|
||||
onLoadMore: () => void;
|
||||
selectedFields: any[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
|
||||
isFrequencyChartVisible: boolean;
|
||||
options: {
|
||||
maxLinesPerRow: number;
|
||||
fontSize: FontSize;
|
||||
};
|
||||
}
|
||||
|
||||
function ColumnView({
|
||||
logs,
|
||||
onLoadMore,
|
||||
selectedFields,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFrequencyChartVisible,
|
||||
options,
|
||||
}: ColumnViewProps): JSX.Element {
|
||||
const {
|
||||
activeLog,
|
||||
onSetActiveLog: handleSetActiveLog,
|
||||
onClearActiveLog: handleClearActiveLog,
|
||||
onAddToQuery: handleAddToQuery,
|
||||
} = useActiveLog();
|
||||
|
||||
const [showActiveLog, setShowActiveLog] = useState<boolean>(false);
|
||||
|
||||
const { queryData: activeLogId } = useUrlQueryData<string | null>(
|
||||
QueryParams.activeLogId,
|
||||
null,
|
||||
);
|
||||
|
||||
const scrollToIndexRef = useRef<
|
||||
| ((
|
||||
rowIndex: number,
|
||||
options?: { align?: 'start' | 'center' | 'end' },
|
||||
) => void)
|
||||
| undefined
|
||||
>();
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLogId) {
|
||||
const log = logs.find(({ id }) => id === activeLogId);
|
||||
|
||||
if (log) {
|
||||
handleSetActiveLog(log);
|
||||
setShowActiveLog(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tableViewProps = {
|
||||
logs,
|
||||
fields: selectedFields,
|
||||
linesPerRow: options.maxLinesPerRow as number,
|
||||
fontSize: options.fontSize as FontSize,
|
||||
appendTo: 'end' as const,
|
||||
activeLogIndex: 0,
|
||||
};
|
||||
|
||||
const { dataSource, columns } = useTableView({
|
||||
...tableViewProps,
|
||||
onClickExpand: handleSetActiveLog,
|
||||
});
|
||||
|
||||
const { draggedColumns, onColumnOrderChange } = useDragColumns<
|
||||
Record<string, unknown>
|
||||
>(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => getDraggedColumns<Record<string, unknown>>(columns, draggedColumns),
|
||||
[columns, draggedColumns],
|
||||
);
|
||||
|
||||
const scrollToLog = useCallback(
|
||||
(logId: string): void => {
|
||||
const logIndex = logs.findIndex((log) => log.id === logId);
|
||||
|
||||
if (logIndex !== -1 && scrollToIndexRef.current) {
|
||||
scrollToIndexRef.current(logIndex, { align: 'center' });
|
||||
}
|
||||
},
|
||||
[logs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLogId) {
|
||||
scrollToLog(activeLogId);
|
||||
}
|
||||
}, [activeLogId]);
|
||||
|
||||
const args = {
|
||||
columns,
|
||||
tableId: 'virtualized-infinite-reorder-resize',
|
||||
enableSorting: false,
|
||||
enableFiltering: false,
|
||||
enableGlobalFilter: false,
|
||||
enableColumnReordering: true,
|
||||
enableColumnResizing: true,
|
||||
enableColumnPinning: false,
|
||||
enableRowSelection: false,
|
||||
enablePagination: false,
|
||||
showHeaders: true,
|
||||
defaultColumnWidth: 180,
|
||||
minColumnWidth: 80,
|
||||
maxColumnWidth: 480,
|
||||
// Virtualization + Infinite Scroll
|
||||
enableVirtualization: true,
|
||||
estimateRowSize: 56,
|
||||
overscan: 50,
|
||||
rowHeight: 56,
|
||||
enableInfiniteScroll: true,
|
||||
enableScrollRestoration: false,
|
||||
fixedHeight: isFrequencyChartVisible ? 560 : 760,
|
||||
enableDynamicRowHeight: true,
|
||||
};
|
||||
|
||||
const selectedColumns = useMemo(
|
||||
() =>
|
||||
tableColumns.map((field) => ({
|
||||
id: field.key?.toString().toLowerCase().replace(/\./g, '_'), // IMP - Replace dots with underscores as reordering does not work well for accessorKey with dots
|
||||
// accessorKey: field.name,
|
||||
accessorFn: (row: Record<string, string>): string =>
|
||||
row[field.key as string] as string,
|
||||
header: field.title as string,
|
||||
size: field.key === 'state-indicator' ? 4 : 180,
|
||||
minSize: field.key === 'state-indicator' ? 4 : 120,
|
||||
maxSize: field.key === 'state-indicator' ? 4 : Number.MAX_SAFE_INTEGER,
|
||||
disableReorder: field.key === 'state-indicator',
|
||||
disableDropBefore: field.key === 'state-indicator',
|
||||
disableResizing: field.key === 'state-indicator',
|
||||
cell: ({
|
||||
row,
|
||||
getValue,
|
||||
}: {
|
||||
row: Row<Record<string, string>>;
|
||||
getValue: () => string | JSX.Element;
|
||||
}): string | JSX.Element => {
|
||||
if (field.key === 'state-indicator') {
|
||||
const fontSize = options.fontSize as FontSize;
|
||||
|
||||
return (
|
||||
<LogStateIndicator
|
||||
severityText={row.original?.severity_text}
|
||||
fontSize={fontSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isTimestamp = field.key === 'timestamp';
|
||||
const cellContent = getValue();
|
||||
|
||||
if (isTimestamp) {
|
||||
const formattedTimestamp = dayjs(cellContent as string).tz(
|
||||
timezone.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="table-cell-content">
|
||||
{formattedTimestamp.format(DATE_TIME_FORMATS.ISO_DATETIME_MS)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`table-cell-content ${
|
||||
row.original.id === activeLog?.id ? 'active-log' : ''
|
||||
}`}
|
||||
>
|
||||
{cellContent}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})),
|
||||
[tableColumns, options.fontSize, activeLog?.id],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = (newColumns: ColumnDef<any>[]): void => {
|
||||
if (isEmpty(newColumns) || isEqual(newColumns, selectedColumns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedColumns = newColumns.map((column) => ({
|
||||
id: column.id,
|
||||
header: column.header,
|
||||
size: column.size,
|
||||
minSize: column.minSize,
|
||||
maxSize: column.maxSize,
|
||||
key: column.id,
|
||||
title: column.header as string,
|
||||
dataIndex: column.id,
|
||||
}));
|
||||
|
||||
onColumnOrderChange(formattedColumns);
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
|
||||
const currentLog = logs.find(({ id }) => id === row.original.id);
|
||||
|
||||
setShowActiveLog(true);
|
||||
handleSetActiveLog(currentLog as ILog);
|
||||
};
|
||||
|
||||
const removeQueryParam = (key: string): void => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(key);
|
||||
window.history.replaceState({}, '', url);
|
||||
};
|
||||
|
||||
const handleLogDetailClose = (): void => {
|
||||
removeQueryParam(QueryParams.activeLogId);
|
||||
handleClearActiveLog();
|
||||
setShowActiveLog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`logs-list-table-view-container ${
|
||||
options.fontSize as FontSize
|
||||
} max-lines-${options.maxLinesPerRow as number}`}
|
||||
data-max-lines-per-row={options.maxLinesPerRow}
|
||||
data-font-size={options.fontSize}
|
||||
>
|
||||
<DataTable
|
||||
{...args}
|
||||
columns={selectedColumns as ColumnDef<Record<string, string>, unknown>[]}
|
||||
data={dataSource}
|
||||
hasMore
|
||||
onLoadMore={onLoadMore}
|
||||
loadingMore={isLoading || isFetching}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onRowClick={handleRowClick}
|
||||
scrollToIndexRef={scrollToIndexRef}
|
||||
/>
|
||||
|
||||
{showActiveLog && activeLog && (
|
||||
<LogDetail
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={handleLogDetailClose}
|
||||
onAddToQuery={handleAddToQuery}
|
||||
onClickActionItem={handleAddToQuery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnView;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
|
||||
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
|
||||
import { InfinityTableProps } from '../InfinityTableView/types';
|
||||
import { OrderedColumn, TanStackTableRowData } from './types';
|
||||
import { getColumnId } from './utils';
|
||||
|
||||
import './styles/TanStackHeaderRow.styles.scss';
|
||||
|
||||
type TanStackHeaderRowProps = {
|
||||
column: OrderedColumn;
|
||||
header?: TanStackHeader<TanStackTableRowData, unknown>;
|
||||
isDarkMode: boolean;
|
||||
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
|
||||
hasSingleColumn: boolean;
|
||||
canRemoveColumn?: boolean;
|
||||
onRemoveColumn?: (columnKey: string) => void;
|
||||
};
|
||||
|
||||
const GRIP_ICON_SIZE = 12;
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function TanStackHeaderRow({
|
||||
column,
|
||||
header,
|
||||
isDarkMode,
|
||||
fontSize,
|
||||
hasSingleColumn,
|
||||
canRemoveColumn = false,
|
||||
onRemoveColumn,
|
||||
}: 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.key !== 'expand' &&
|
||||
column.key !== 'state-indicator',
|
||||
);
|
||||
const isResizing = Boolean(header?.column.getIsResizing());
|
||||
const resizeHandler = header?.getResizeHandler();
|
||||
const headerText =
|
||||
typeof column.title === 'string' && column.title
|
||||
? column.title
|
||||
: String(header?.id ?? columnId);
|
||||
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
|
||||
const handleResizeStart = (
|
||||
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
|
||||
): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeHandler?.(event);
|
||||
};
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: columnId,
|
||||
disabled: !isDragColumn,
|
||||
});
|
||||
const headerCellStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
|
||||
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
|
||||
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
|
||||
} as CSSProperties),
|
||||
[isResizing, transform?.x, transform?.y, transition],
|
||||
);
|
||||
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 (
|
||||
<TableHeaderCellStyled
|
||||
ref={setNodeRef}
|
||||
$isLogIndicator={column.key === 'state-indicator'}
|
||||
$isDarkMode={isDarkMode}
|
||||
$isDragColumn={false}
|
||||
className={headerCellClassName}
|
||||
key={columnId}
|
||||
fontSize={fontSize}
|
||||
$hasSingleColumn={hasSingleColumn}
|
||||
style={headerCellStyle}
|
||||
>
|
||||
<span className={headerContentClassName}>
|
||||
{isDragColumn ? (
|
||||
<span className="tanstack-grip-slot">
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
role="button"
|
||||
aria-label={`Drag ${String(
|
||||
column.title || header?.id || columnId,
|
||||
)} column`}
|
||||
className="tanstack-grip-activator"
|
||||
>
|
||||
<GripVertical size={GRIP_ICON_SIZE} />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="tanstack-header-title" title={headerTitleAttr}>
|
||||
{header
|
||||
? flexRender(header.column.columnDef.header, header.getContext())
|
||||
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
{isColumnRemovable && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
aria-label={`Column actions for ${headerTitleAttr}`}
|
||||
className="tanstack-header-action-trigger"
|
||||
onMouseDown={(event): void => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="tanstack-column-actions-content"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tanstack-remove-column-action"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemoveColumn?.(String(column.key));
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="tanstack-remove-column-action-icon" />
|
||||
Remove column
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</span>
|
||||
{isResizableColumn && (
|
||||
<span
|
||||
role="presentation"
|
||||
className="cursor-col-resize"
|
||||
title="Drag to resize column"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
onTouchStart={(event): void => {
|
||||
handleResizeStart(event);
|
||||
}}
|
||||
>
|
||||
<span className="tanstack-resize-handle-line" />
|
||||
</span>
|
||||
)}
|
||||
</TableHeaderCellStyled>
|
||||
);
|
||||
}
|
||||
|
||||
export default TanStackHeaderRow;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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/ui', () => ({
|
||||
...jest.requireActual('@signozhq/ui'),
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
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/ui';
|
||||
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 { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
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 = getAbsoluteUrl(`${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);
|
||||
@@ -1,8 +1,8 @@
|
||||
.tanstackHeaderCell {
|
||||
.tanstack-header-cell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 0.3rem;
|
||||
padding: 0;
|
||||
transform: translate3d(
|
||||
var(--tanstack-header-translate-x, 0px),
|
||||
var(--tanstack-header-translate-y, 0px),
|
||||
@@ -10,11 +10,11 @@
|
||||
);
|
||||
transition: var(--tanstack-header-transition, none);
|
||||
|
||||
&.isDragging {
|
||||
&.is-dragging {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.isResizing {
|
||||
&.is-resizing {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
background-color: var(--l2-background) !important;
|
||||
}
|
||||
|
||||
.tanstackHeaderContent {
|
||||
.tanstack-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
@@ -31,20 +31,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;
|
||||
@@ -54,7 +54,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tanstackGripActivator {
|
||||
.tanstack-grip-activator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -66,7 +66,7 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tanstackHeaderActionTrigger {
|
||||
.tanstack-header-action-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -75,11 +75,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);
|
||||
@@ -88,7 +86,7 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tanstackRemoveColumnAction {
|
||||
.tanstack-remove-column-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -110,19 +108,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;
|
||||
@@ -134,84 +132,25 @@
|
||||
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;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l2-border);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transition: background 120ms ease, width 120ms ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cursorColResize:hover .tanstackResizeHandleLine {
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
|
||||
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
|
||||
width: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.tanstackSortButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSorted {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.tanstackHeaderTitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tanstackSortLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tanstackSortIndicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.isSortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,48 +1,33 @@
|
||||
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { toast } from '@signozhq/ui';
|
||||
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 type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
|
||||
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';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
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 { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import APIError from 'types/api/error';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { getAbsoluteUrl } from '@/utils/basePath';
|
||||
|
||||
import NoLogs from '../NoLogs/NoLogs';
|
||||
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
|
||||
import { InfinityWrapperStyled } from './styles';
|
||||
import TanStackTableView from './TanStackTableView';
|
||||
import {
|
||||
convertKeysToColumnFields,
|
||||
getEmptyLogsListConfig,
|
||||
@@ -65,13 +50,8 @@ function LogsExplorerList({
|
||||
isFilterApplied,
|
||||
handleChangeSelectedView,
|
||||
}: LogsExplorerListProps): JSX.Element {
|
||||
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
const { logs: logsPreferences } = usePreferenceContext();
|
||||
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
||||
const hasReconciledHiddenColumnsRef = useRef(false);
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
@@ -81,7 +61,7 @@ function LogsExplorerList({
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator:
|
||||
@@ -100,59 +80,13 @@ function LogsExplorerList({
|
||||
);
|
||||
|
||||
const selectedFields = useMemo(
|
||||
() =>
|
||||
convertKeysToColumnFields([
|
||||
...defaultLogsSelectedColumns,
|
||||
...options.selectColumns,
|
||||
]),
|
||||
() => convertKeysToColumnFields(options.selectColumns),
|
||||
[options],
|
||||
);
|
||||
|
||||
const syncedSelectedColumns = useMemo(
|
||||
() =>
|
||||
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
|
||||
[options.selectColumns, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const handleColumnRemove = useCallback(
|
||||
(columnId: string) => {
|
||||
const updatedColumns = options.selectColumns.filter(
|
||||
({ name }) => name !== columnId,
|
||||
);
|
||||
logsPreferences.updateColumns(updatedColumns);
|
||||
},
|
||||
[options.selectColumns, logsPreferences],
|
||||
);
|
||||
|
||||
const logsColumns = useLogsTableColumns({
|
||||
fields: selectedFields,
|
||||
fontSize: options.fontSize,
|
||||
appendTo: 'end',
|
||||
});
|
||||
|
||||
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 = getAbsoluteUrl(
|
||||
`${window.location.pathname}?${urlQuery.toString()}`,
|
||||
);
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||
},
|
||||
[setCopy],
|
||||
);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
virtuosoRef: ref as React.RefObject<Pick<
|
||||
VirtuosoHandle,
|
||||
'scrollToIndex'
|
||||
> | null>,
|
||||
virtuosoRef: ref,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -163,20 +97,6 @@ function LogsExplorerList({
|
||||
}
|
||||
}, [isLoading, isFetching, isError, logs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasReconciledHiddenColumnsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReconciledHiddenColumnsRef.current = true;
|
||||
|
||||
if (syncedSelectedColumns.length === options.selectColumns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
logsPreferences.updateColumns(syncedSelectedColumns);
|
||||
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, log: ILog): JSX.Element => {
|
||||
if (options.format === 'raw') {
|
||||
@@ -235,46 +155,25 @@ function LogsExplorerList({
|
||||
|
||||
if (options.format === 'table') {
|
||||
return (
|
||||
<TanStackTable<ILog>
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
onColumnRemove={handleColumnRemove}
|
||||
plainTextCellLineClamp={options.maxLines}
|
||||
cellTypographySize={options.fontSize}
|
||||
data={logs}
|
||||
isLoading={isLoading || isFetching}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -299,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}
|
||||
@@ -320,11 +219,12 @@ function LogsExplorerList({
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
isFetching,
|
||||
selectedFields,
|
||||
handleChangeSelectedView,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
activeLog,
|
||||
isDarkMode,
|
||||
makeOnLogCopy,
|
||||
config.addColumn?.onRemove,
|
||||
]);
|
||||
|
||||
const isTraceToLogsNavigation = useMemo(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"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/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -62,6 +63,7 @@ type provider struct {
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanMapperHandler spanmapper.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
@@ -92,6 +94,7 @@ func NewFactory(
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
@@ -125,6 +128,7 @@ func NewFactory(
|
||||
factoryHandler,
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
@@ -160,6 +164,7 @@ func newProvider(
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
@@ -193,6 +198,7 @@ func newProvider(
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanMapperHandler: spanMapperHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
@@ -300,6 +306,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSpanMapperRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addAlertmanagerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
171
pkg/apiserver/signozapiserver/spanmapper.go
Normal file
171
pkg/apiserver/signozapiserver/spanmapper.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/span_mapper_groups", handler.New(
|
||||
provider.authZ.ViewAccess(provider.spanMapperHandler.ListGroups),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListSpanMapperGroups",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "List span attribute mapping groups",
|
||||
Description: "Returns all span attribute mapping groups for the authenticated org.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
RequestQuery: new(spantypes.ListSpanMapperGroupsQuery),
|
||||
Response: new(spantypes.GettableSpanMapperGroups),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanMapperHandler.CreateGroup),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateSpanMapperGroup",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Create a span attribute mapping group",
|
||||
Description: "Creates a new span attribute mapping group for the org.",
|
||||
Request: new(spantypes.PostableSpanMapperGroup),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableSpanMapperGroup),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanMapperHandler.UpdateGroup),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateSpanMapperGroup",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Update a span attribute mapping group",
|
||||
Description: "Partially updates an existing mapping group's name, condition, or enabled state.",
|
||||
Request: new(spantypes.UpdatableSpanMapperGroup),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanMapperHandler.DeleteGroup),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteSpanMapperGroup",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Delete a span attribute mapping group",
|
||||
Description: "Hard-deletes a mapping group and cascades to all its mappers.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers", handler.New(
|
||||
provider.authZ.ViewAccess(provider.spanMapperHandler.ListMappers),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListSpanMappers",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "List span mappers for a group",
|
||||
Description: "Returns all mappers belonging to a mapping group.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(spantypes.GettableSpanMapperGroups),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanMapperHandler.CreateMapper),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateSpanMapper",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Create a span mapper",
|
||||
Description: "Adds a new mapper to the specified mapping group.",
|
||||
Request: new(spantypes.PostableSpanMapper),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableSpanMapper),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanMapperHandler.UpdateMapper),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateSpanMapper",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Update a span mapper",
|
||||
Description: "Partially updates an existing mapper's field context, config, or enabled state.",
|
||||
Request: new(spantypes.UpdatableSpanMapper),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}", handler.New(
|
||||
provider.authZ.AdminAccess(provider.spanMapperHandler.DeleteMapper),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteSpanMapper",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Delete a span mapper",
|
||||
Description: "Hard-deletes a mapper from a mapping group.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
},
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -25,8 +25,6 @@ type Config struct {
|
||||
FlushInterval time.Duration `mapstructure:"flush_interval"`
|
||||
|
||||
OTLPHTTP OTLPHTTPConfig `mapstructure:"otlphttp"`
|
||||
|
||||
File FileConfig `mapstructure:"file"`
|
||||
}
|
||||
|
||||
// OTLPHTTPConfig holds configuration for the OTLP HTTP exporter provider.
|
||||
@@ -48,14 +46,6 @@ type OTLPHTTPConfig struct {
|
||||
Retry RetryConfig `mapstructure:"retry"`
|
||||
}
|
||||
|
||||
// FileConfig holds configuration for the file exporter provider.
|
||||
// Audit events are encoded as OTLP-JSON log records and appended to the file.
|
||||
type FileConfig struct {
|
||||
// Path is the absolute path to the audit log file. The file is opened with
|
||||
// O_APPEND|O_CREATE|O_WRONLY; existing contents are preserved across runs.
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
// RetryConfig configures exponential backoff for the OTLP HTTP exporter.
|
||||
type RetryConfig struct {
|
||||
// Enabled controls whether retries are attempted on transient failures.
|
||||
@@ -121,11 +111,5 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Provider == "file" {
|
||||
if c.File.Path == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "auditor::file::path must be set when provider is file")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ var (
|
||||
type Config struct {
|
||||
ExternalURL *url.URL `mapstructure:"external_url"`
|
||||
IngestionURL *url.URL `mapstructure:"ingestion_url"`
|
||||
MCPURL *url.URL `mapstructure:"mcp_url"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
|
||||
@@ -31,14 +31,8 @@ func newProvider(_ context.Context, providerSettings factory.ProviderSettings, c
|
||||
}
|
||||
|
||||
func (provider *provider) GetConfig(context.Context) *globaltypes.Config {
|
||||
var mcpURL *string
|
||||
if provider.config.MCPURL != nil {
|
||||
s := provider.config.MCPURL.String()
|
||||
mcpURL = &s
|
||||
}
|
||||
|
||||
return globaltypes.NewConfig(
|
||||
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String(), mcpURL),
|
||||
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()),
|
||||
globaltypes.NewIdentNConfig(
|
||||
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
|
||||
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},
|
||||
|
||||
85
pkg/modules/spanmapper/implspanmapper/agent_feature.go
Normal file
85
pkg/modules/spanmapper/implspanmapper/agent_feature.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const SpanAttrMappingFeatureType agentConf.AgentFeatureType = "span_attr_mapping"
|
||||
|
||||
// SpanAttrMappingFeature implements agentConf.AgentFeature. It reads enabled
|
||||
// mapping groups and mappers from the module and generates the
|
||||
// signozspanmappingprocessor config for deployment via OpAMP.
|
||||
type SpanAttrMappingFeature struct {
|
||||
module spanmapper.Module
|
||||
}
|
||||
|
||||
func NewSpanAttrMappingFeature(module spanmapper.Module) *SpanAttrMappingFeature {
|
||||
return &SpanAttrMappingFeature{module: module}
|
||||
}
|
||||
|
||||
func (f *SpanAttrMappingFeature) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return SpanAttrMappingFeatureType
|
||||
}
|
||||
|
||||
func (f *SpanAttrMappingFeature) RecommendAgentConfig(
|
||||
orgId valuer.UUID,
|
||||
currentConfYaml []byte,
|
||||
configVersion *opamptypes.AgentConfigVersion,
|
||||
) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
groups, err := f.getEnabled(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := generateCollectorConfigWithSpanMapping(currentConfYaml, groups)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// getEnabled returns enabled groups alongside their enabled mappers. Groups
|
||||
// with no enabled mappers are still included so the collector sees the
|
||||
// exists_any condition, even if the attributes list is empty.
|
||||
func (f *SpanAttrMappingFeature) getEnabled(ctx context.Context, orgId valuer.UUID) ([]enabledGroup, error) {
|
||||
if f.module == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
enabled := true
|
||||
groups, err := f.module.ListGroups(ctx, orgId, &spantypes.ListSpanMapperGroupsQuery{Enabled: &enabled})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]enabledGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
mappers, err := f.module.ListMappers(ctx, orgId, g.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabledMappers := make([]*spantypes.SpanMapper, 0, len(mappers))
|
||||
for _, m := range mappers {
|
||||
if m.Enabled {
|
||||
enabledMappers = append(enabledMappers, m)
|
||||
}
|
||||
}
|
||||
out = append(out, enabledGroup{group: g, mappers: enabledMappers})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
137
pkg/modules/spanmapper/implspanmapper/collector_config.go
Normal file
137
pkg/modules/spanmapper/implspanmapper/collector_config.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const processorName = "signozspanmappingprocessor"
|
||||
|
||||
const (
|
||||
// Collector context values (see signozspanmappingprocessor.ContextAttributes/ContextResource).
|
||||
ctxAttributes = "attributes"
|
||||
ctxResource = "resource"
|
||||
|
||||
// Collector action values.
|
||||
actionCopy = "copy"
|
||||
actionMove = "move"
|
||||
|
||||
// Source key prefix the collector treats as "read from resource".
|
||||
resourcePrefix = "resource."
|
||||
)
|
||||
|
||||
// enabledGroup pairs an enabled group with its enabled mappers.
|
||||
type enabledGroup struct {
|
||||
group *spantypes.SpanMapperGroup
|
||||
mappers []*spantypes.SpanMapper
|
||||
}
|
||||
|
||||
// buildProcessorConfig converts enabled groups + mappers into the
|
||||
// signozspanmappingprocessor config shape.
|
||||
func buildProcessorConfig(groups []enabledGroup) *spantypes.SpanMappingProcessorConfig {
|
||||
out := make([]spantypes.SpanMappingGroup, 0, len(groups))
|
||||
|
||||
for _, eg := range groups {
|
||||
rules := make([]spantypes.SpanMappingAttribute, 0, len(eg.mappers))
|
||||
for _, m := range eg.mappers {
|
||||
rules = append(rules, buildAttributeRule(m))
|
||||
}
|
||||
|
||||
out = append(out, spantypes.SpanMappingGroup{
|
||||
ID: eg.group.Name,
|
||||
ExistsAny: spantypes.SpanMappingExistsAny{
|
||||
Attributes: eg.group.Condition.Attributes,
|
||||
Resource: eg.group.Condition.Resource,
|
||||
},
|
||||
Attributes: rules,
|
||||
})
|
||||
}
|
||||
|
||||
return &spantypes.SpanMappingProcessorConfig{Groups: out}
|
||||
}
|
||||
|
||||
// buildAttributeRule maps a single SpanMapper to a collector AttributeRule.
|
||||
// Sources are sorted by Priority DESC (highest-priority first), and read-from-
|
||||
// resource sources are encoded via the "resource." prefix. The rule-level
|
||||
// action is derived from the sources' operations (all sources within one
|
||||
// mapper are expected to share the same operation; the highest-priority
|
||||
// source's operation is used).
|
||||
func buildAttributeRule(m *spantypes.SpanMapper) spantypes.SpanMappingAttribute {
|
||||
sources := make([]spantypes.SpanMapperSource, len(m.Config.Sources))
|
||||
copy(sources, m.Config.Sources)
|
||||
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
|
||||
|
||||
keys := make([]string, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
if s.Context == spantypes.FieldContextResource {
|
||||
keys = append(keys, resourcePrefix+s.Key)
|
||||
} else {
|
||||
keys = append(keys, s.Key)
|
||||
}
|
||||
}
|
||||
|
||||
action := actionCopy
|
||||
if len(sources) > 0 && sources[0].Operation == spantypes.SpanMapperOperationMove {
|
||||
action = actionMove
|
||||
}
|
||||
|
||||
ctx := ctxAttributes
|
||||
if m.FieldContext == spantypes.FieldContextResource {
|
||||
ctx = ctxResource
|
||||
}
|
||||
|
||||
return spantypes.SpanMappingAttribute{
|
||||
Target: m.Name,
|
||||
Context: ctx,
|
||||
Action: action,
|
||||
Sources: keys,
|
||||
}
|
||||
}
|
||||
|
||||
// generateCollectorConfigWithSpanMapping injects (or replaces) the
|
||||
// signozspanmappingprocessor block in the collector YAML. Pipeline wiring is
|
||||
// handled by the collector's baseline config, not here.
|
||||
func generateCollectorConfigWithSpanMapping(
|
||||
currentConfYaml []byte,
|
||||
groups []enabledGroup,
|
||||
) ([]byte, error) {
|
||||
// Empty input: nothing to inject into. Pass through unchanged so we don't
|
||||
// turn it into "null\n" or fail on yaml.v3's EOF.
|
||||
if len(bytes.TrimSpace(currentConfYaml)) == 0 {
|
||||
return currentConfYaml, nil
|
||||
}
|
||||
|
||||
var collectorConf map[string]any
|
||||
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal collector config: %w", err)
|
||||
}
|
||||
if collectorConf == nil {
|
||||
collectorConf = map[string]any{}
|
||||
}
|
||||
|
||||
processors := map[string]any{}
|
||||
if collectorConf["processors"] != nil {
|
||||
if p, ok := collectorConf["processors"].(map[string]any); ok {
|
||||
processors = p
|
||||
}
|
||||
}
|
||||
|
||||
procConfig := buildProcessorConfig(groups)
|
||||
configBytes, err := yaml.Marshal(procConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal span attr mapping processor config: %w", err)
|
||||
}
|
||||
var configMap any
|
||||
if err := yaml.Unmarshal(configBytes, &configMap); err != nil {
|
||||
return nil, fmt.Errorf("failed to re-unmarshal span attr mapping processor config: %w", err)
|
||||
}
|
||||
|
||||
processors[processorName] = configMap
|
||||
collectorConf["processors"] = processors
|
||||
|
||||
return yaml.Marshal(collectorConf)
|
||||
}
|
||||
301
pkg/modules/spanmapper/implspanmapper/handler.go
Normal file
301
pkg/modules/spanmapper/implspanmapper/handler.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module spanmapper.Module
|
||||
providerSettings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewHandler(module spanmapper.Module, providerSettings factory.ProviderSettings) spanmapper.Handler {
|
||||
return &handler{module: module, providerSettings: providerSettings}
|
||||
}
|
||||
|
||||
// ListGroups handles GET /api/v1/span_mapper_groups.
|
||||
func (h *handler) ListGroups(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var q spantypes.ListSpanMapperGroupsQuery
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), &q); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := h.module.ListGroups(ctx, orgID, &q)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spantypes.NewGettableSpanMapperGroups(groups))
|
||||
}
|
||||
|
||||
// CreateGroup handles POST /api/v1/span_mapper_groups.
|
||||
func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
req := new(spantypes.PostableSpanMapperGroup)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
group := spantypes.NewSpanMapperGroupFromPostable(req)
|
||||
|
||||
err = h.module.CreateGroup(ctx, orgID, claims.Email, group)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusCreated, group)
|
||||
}
|
||||
|
||||
// UpdateGroup handles PUT /api/v1/span_mapper_groups/{id}.
|
||||
func (h *handler) UpdateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.UpdatableSpanMapperGroup)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.module.UpdateGroup(ctx, orgID, id, claims.Email, spantypes.NewSpanMapperGroupFromUpdatable(req))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// DeleteGroup handles DELETE /api/v1/span_mapper_groups/{id}.
|
||||
func (h *handler) DeleteGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.module.DeleteGroup(ctx, orgID, id); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// ListMappers handles GET /api/v1/span_mapper_groups/{id}/span_mappers.
|
||||
func (h *handler) ListMappers(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
mappers, err := h.module.ListMappers(ctx, orgID, groupID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spantypes.NewGettableSpanMappers(mappers))
|
||||
}
|
||||
|
||||
// CreateMapper handles POST /api/v1/span_mapper_groups/{id}/span_mappers.
|
||||
func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.PostableSpanMapper)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
mapper := spantypes.NewSpanMapperFromPostable(req)
|
||||
|
||||
err = h.module.CreateMapper(ctx, orgID, groupID, claims.Email, mapper)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, mapper)
|
||||
}
|
||||
|
||||
// UpdateMapper handles PUT /api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}.
|
||||
func (h *handler) UpdateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
mapperID, err := mapperIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.UpdatableSpanMapper)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.module.UpdateMapper(ctx, orgID, groupID, mapperID, claims.Email, spantypes.NewSpanMapperFromUpdatable(req))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// DeleteMapper handles DELETE /api/v1/span_mapper_groups/{groupId}/span_mappers/{mapperId}.
|
||||
func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
groupID, err := groupIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
mapperID, err := mapperIDFromPath(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.module.DeleteMapper(ctx, orgID, groupID, mapperID); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
|
||||
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
|
||||
vars := mux.Vars(r)
|
||||
raw := vars["groupId"]
|
||||
id, err := valuer.NewUUID(raw)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "group id is not a valid uuid")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// mapperIDFromPath extracts and validates the {mapperId} path variable.
|
||||
func mapperIDFromPath(r *http.Request) (valuer.UUID, error) {
|
||||
raw := mux.Vars(r)["mapperId"]
|
||||
id, err := valuer.NewUUID(raw)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "mapper id is not a valid uuid")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
179
pkg/modules/spanmapper/implspanmapper/module.go
Normal file
179
pkg/modules/spanmapper/implspanmapper/module.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store spantypes.Store
|
||||
}
|
||||
|
||||
func NewModule(store spantypes.Store) spanmapper.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
func (m *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
storables, err := m.store.ListSpanMapperGroups(ctx, orgID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewSpanMapperGroupsFromStorableGroups(storables), nil
|
||||
}
|
||||
|
||||
func (m *module) GetGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*spantypes.SpanMapperGroup, error) {
|
||||
s, err := m.store.GetSpanMapperGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewSpanMapperGroupFromStorable(s), nil
|
||||
}
|
||||
|
||||
func (m *module) CreateGroup(ctx context.Context, orgID valuer.UUID, createdBy string, group *spantypes.SpanMapperGroup) error {
|
||||
now := time.Now()
|
||||
group.ID = valuer.GenerateUUID()
|
||||
group.OrgID = orgID
|
||||
group.CreatedAt = now
|
||||
group.UpdatedAt = now
|
||||
group.CreatedBy = createdBy
|
||||
group.UpdatedBy = createdBy
|
||||
|
||||
storable := &spantypes.StorableSpanMapperGroup{
|
||||
Identifiable: types.Identifiable{ID: group.ID},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
|
||||
OrgID: orgID,
|
||||
Name: group.Name,
|
||||
Category: group.Category,
|
||||
Condition: group.Condition,
|
||||
Enabled: group.Enabled,
|
||||
}
|
||||
|
||||
if err := m.store.CreateSpanMapperGroup(ctx, storable); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) UpdateGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, group *spantypes.SpanMapperGroup) error {
|
||||
existing, err := m.store.GetSpanMapperGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.Name != "" {
|
||||
existing.Name = group.Name
|
||||
}
|
||||
if len(group.Condition.Attributes) > 0 || len(group.Condition.Resource) > 0 {
|
||||
existing.Condition = group.Condition
|
||||
}
|
||||
existing.Enabled = group.Enabled
|
||||
existing.UpdatedAt = time.Now()
|
||||
existing.UpdatedBy = updatedBy
|
||||
|
||||
if err := m.store.UpdateSpanMapperGroup(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) DeleteGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
if err := m.store.DeleteSpanMapperGroup(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) ListMappers(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID) ([]*spantypes.SpanMapper, error) {
|
||||
storables, err := m.store.ListSpanMappers(ctx, orgID, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewSpanMappersFromStorableSpanMappers(storables), nil
|
||||
}
|
||||
|
||||
func (m *module) GetMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID) (*spantypes.SpanMapper, error) {
|
||||
s, err := m.store.GetSpanMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewSpanMapperFromStorable(s), nil
|
||||
}
|
||||
|
||||
func (m *module) CreateMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, createdBy string, mapper *spantypes.SpanMapper) error {
|
||||
// Ensure the group belongs to the org before inserting the child row.
|
||||
if _, err := m.store.GetSpanMapperGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
mapper.ID = valuer.GenerateUUID()
|
||||
mapper.GroupID = groupID
|
||||
mapper.CreatedAt = now
|
||||
mapper.UpdatedAt = now
|
||||
mapper.CreatedBy = createdBy
|
||||
mapper.UpdatedBy = createdBy
|
||||
|
||||
storable := &spantypes.StorableSpanMapper{
|
||||
Identifiable: types.Identifiable{ID: mapper.ID},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
|
||||
GroupID: groupID,
|
||||
Name: mapper.Name,
|
||||
FieldContext: mapper.FieldContext,
|
||||
Config: mapper.Config,
|
||||
Enabled: mapper.Enabled,
|
||||
}
|
||||
|
||||
if err := m.store.CreateSpanMapper(ctx, storable); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) UpdateMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID, updatedBy string, mapper *spantypes.SpanMapper) error {
|
||||
existing, err := m.store.GetSpanMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mapper.FieldContext != (spantypes.FieldContext{}) {
|
||||
existing.FieldContext = mapper.FieldContext
|
||||
}
|
||||
if mapper.Config.Sources != nil {
|
||||
existing.Config = mapper.Config
|
||||
}
|
||||
existing.Enabled = mapper.Enabled
|
||||
existing.UpdatedAt = time.Now()
|
||||
existing.UpdatedBy = updatedBy
|
||||
|
||||
if err := m.store.UpdateSpanMapper(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) DeleteMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID) error {
|
||||
if err := m.store.DeleteSpanMapper(ctx, orgID, groupID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
236
pkg/modules/spanmapper/implspanmapper/store.go
Normal file
236
pkg/modules/spanmapper/implspanmapper/store.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) spantypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) ListSpanMapperGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.StorableSpanMapperGroup, error) {
|
||||
groups := make([]*spantypes.StorableSpanMapperGroup, 0)
|
||||
|
||||
sel := s.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&groups).
|
||||
Where("org_id = ?", orgID)
|
||||
|
||||
if q != nil {
|
||||
if q.Category != nil {
|
||||
sel = sel.Where("category = ?", valuer.String(*q.Category).StringValue())
|
||||
}
|
||||
if q.Enabled != nil {
|
||||
sel = sel.Where("enabled = ?", *q.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sel.Order("created_at DESC").Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *store) GetSpanMapperGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes.StorableSpanMapperGroup, error) {
|
||||
group := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(group).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateSpanMapperGroup(ctx context.Context, group *spantypes.StorableSpanMapperGroup) error {
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(group).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return s.sqlstore.WrapAlreadyExistsErrf(err, spantypes.ErrCodeMappingGroupAlreadyExists, "span mapper group %q already exists", group.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateSpanMapperGroup(ctx context.Context, group *spantypes.StorableSpanMapperGroup) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(group).
|
||||
Where("org_id = ?", group.OrgID).
|
||||
Where("id = ?", group.ID).
|
||||
ExcludeColumn("id", "org_id", "created_at", "created_by").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", group.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) DeleteSpanMapperGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
tx, err := s.sqlstore.BunDBCtx(ctx).BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *store) ListSpanMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]*spantypes.StorableSpanMapper, error) {
|
||||
mappers := make([]*spantypes.StorableSpanMapper, 0)
|
||||
|
||||
// Scope by org via the parent group's org_id.
|
||||
if _, err := s.GetSpanMapperGroup(ctx, orgID, groupID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&mappers).
|
||||
Where("group_id = ?", groupID).
|
||||
Order("created_at DESC").
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mappers, nil
|
||||
}
|
||||
|
||||
func (s *store) GetSpanMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (*spantypes.StorableSpanMapper, error) {
|
||||
// Ensure the group belongs to the org.
|
||||
if _, err := s.GetSpanMapperGroup(ctx, orgID, groupID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mapper := new(spantypes.StorableSpanMapper)
|
||||
err := s.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(mapper).
|
||||
Where("group_id = ?", groupID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMapperNotFound, "span mapper %s not found", id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateSpanMapper(ctx context.Context, mapper *spantypes.StorableSpanMapper) error {
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(mapper).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return s.sqlstore.WrapAlreadyExistsErrf(err, spantypes.ErrCodeMapperAlreadyExists, "span mapper %q already exists", mapper.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateSpanMapper(ctx context.Context, mapper *spantypes.StorableSpanMapper) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(mapper).
|
||||
Where("group_id = ?", mapper.GroupID).
|
||||
Where("id = ?", mapper.ID).
|
||||
ExcludeColumn("id", "group_id", "created_at", "created_by").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMapperNotFound, "span mapper %s not found", mapper.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) DeleteSpanMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
if _, err := s.GetSpanMapperGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", groupID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMapperNotFound, "span mapper %s not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
pkg/modules/spanmapper/spanmapper.go
Normal file
41
pkg/modules/spanmapper/spanmapper.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package spanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module defines the business logic for span attribute mapping groups and mappers.
|
||||
type Module interface {
|
||||
// Group operations
|
||||
ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error)
|
||||
GetGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*spantypes.SpanMapperGroup, error)
|
||||
CreateGroup(ctx context.Context, orgID valuer.UUID, createdBy string, group *spantypes.SpanMapperGroup) error
|
||||
UpdateGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, group *spantypes.SpanMapperGroup) error
|
||||
DeleteGroup(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
// Mapper operations
|
||||
ListMappers(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID) ([]*spantypes.SpanMapper, error)
|
||||
GetMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID) (*spantypes.SpanMapper, error)
|
||||
CreateMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, createdBy string, mapper *spantypes.SpanMapper) error
|
||||
UpdateMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID, updatedBy string, mapper *spantypes.SpanMapper) error
|
||||
DeleteMapper(ctx context.Context, orgID valuer.UUID, groupID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
type Handler interface {
|
||||
// Group handlers
|
||||
ListGroups(rw http.ResponseWriter, r *http.Request)
|
||||
CreateGroup(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateGroup(rw http.ResponseWriter, r *http.Request)
|
||||
DeleteGroup(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Mapper handlers
|
||||
ListMappers(rw http.ResponseWriter, r *http.Request)
|
||||
CreateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
DeleteMapper(rw http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
@@ -129,11 +130,14 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
spanAttrMappingFeature := implspanmapper.NewSpanAttrMappingFeature(signoz.Modules.SpanMapper)
|
||||
|
||||
agentConfMgr, err := agentConf.Initiate(
|
||||
&agentConf.ManagerOptions{
|
||||
Store: signoz.SQLStore,
|
||||
AgentFeatures: []agentConf.AgentFeature{
|
||||
logParsingPipelineController,
|
||||
spanAttrMappingFeature,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,6 +36,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services/implservices"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
@@ -71,6 +73,7 @@ type Handlers struct {
|
||||
RegistryHandler factory.Handler
|
||||
CloudIntegrationHandler cloudintegration.Handler
|
||||
RuleStateHistory rulestatehistory.Handler
|
||||
SpanMapperHandler spanmapper.Handler
|
||||
AlertmanagerHandler alertmanager.Handler
|
||||
TraceDetail tracedetail.Handler
|
||||
RulerHandler ruler.Handler
|
||||
@@ -114,6 +117,7 @@ func NewHandlers(
|
||||
RegistryHandler: registryHandler,
|
||||
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
|
||||
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
|
||||
SpanMapperHandler: implspanmapper.NewHandler(modules.SpanMapper, providerSettings),
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
|
||||
@@ -37,6 +37,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/services/implservices"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
@@ -79,6 +81,7 @@ type Modules struct {
|
||||
CloudIntegration cloudintegration.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -131,5 +134,6 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"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/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -74,6 +75,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ factory.Handler }{},
|
||||
struct{ cloudintegration.Handler }{},
|
||||
struct{ rulestatehistory.Handler }{},
|
||||
struct{ spanmapper.Handler }{},
|
||||
struct{ alertmanager.Handler }{},
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
|
||||
@@ -195,6 +195,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
|
||||
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
|
||||
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,6 +282,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RegistryHandler,
|
||||
handlers.CloudIntegrationHandler,
|
||||
handlers.RuleStateHistory,
|
||||
handlers.SpanMapperHandler,
|
||||
handlers.AlertmanagerHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
|
||||
119
pkg/sqlmigration/082_add_span_mapper.go
Normal file
119
pkg/sqlmigration/082_add_span_mapper.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addSpanMapper struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddSpanMapperFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_span_mapper"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
|
||||
return &addSpanMapper{sqlschema: sqlschema, sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addSpanMapper) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addSpanMapper) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
groupSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "span_mapper_group",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "category", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "condition", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "enabled", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "true"},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, groupSQLs...)
|
||||
|
||||
groupIdxSQLs := migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "span_mapper_group",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
|
||||
})
|
||||
sqls = append(sqls, groupIdxSQLs...)
|
||||
|
||||
mapperSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "span_mapper",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "group_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "field_context", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "config", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "enabled", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "true"},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("group_id"),
|
||||
ReferencedTableName: sqlschema.TableName("span_mapper_group"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, mapperSQLs...)
|
||||
|
||||
mapperIdxSQLs := migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "span_mapper",
|
||||
ColumnNames: []sqlschema.ColumnName{"group_id", "name"},
|
||||
})
|
||||
sqls = append(sqls, mapperIdxSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addSpanMapper) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package globaltypes
|
||||
|
||||
type Endpoint struct {
|
||||
ExternalURL string `json:"external_url" required:"true"`
|
||||
IngestionURL string `json:"ingestion_url" required:"true"`
|
||||
MCPURL *string `json:"mcp_url" required:"true" nullable:"true"`
|
||||
ExternalURL string `json:"external_url"`
|
||||
IngestionURL string `json:"ingestion_url"`
|
||||
}
|
||||
|
||||
func NewEndpoint(externalURL, ingestionURL string, mcpURL *string) Endpoint {
|
||||
func NewEndpoint(externalURL, ingestionURL string) Endpoint {
|
||||
return Endpoint{
|
||||
ExternalURL: externalURL,
|
||||
IngestionURL: ingestionURL,
|
||||
MCPURL: mcpURL,
|
||||
}
|
||||
}
|
||||
|
||||
137
pkg/types/spantypes/mapper.go
Normal file
137
pkg/types/spantypes/mapper.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeMapperNotFound = errors.MustNewCode("span_attribute_mapper_not_found")
|
||||
ErrCodeMapperAlreadyExists = errors.MustNewCode("span_attribute_mapper_already_exists")
|
||||
ErrCodeMappingInvalidInput = errors.MustNewCode("span_attribute_mapping_invalid_input")
|
||||
)
|
||||
|
||||
// FieldContext is where the target attribute is written.
|
||||
type FieldContext struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
FieldContextSpanAttribute = FieldContext{valuer.NewString("attribute")}
|
||||
FieldContextResource = FieldContext{valuer.NewString("resource")}
|
||||
)
|
||||
|
||||
// MapperOperation determines whether the source attribute is moved (deleted) or copied.
|
||||
type SpanMapperOperation struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SpanMapperOperationMove = SpanMapperOperation{valuer.NewString("move")}
|
||||
SpanMapperOperationCopy = SpanMapperOperation{valuer.NewString("copy")}
|
||||
)
|
||||
|
||||
// MapperSource describes one candidate source for a target attribute.
|
||||
type SpanMapperSource struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Context FieldContext `json:"context" required:"true"`
|
||||
Operation SpanMapperOperation `json:"operation" required:"true"`
|
||||
Priority int `json:"priority" required:"true"`
|
||||
}
|
||||
|
||||
// MapperConfig holds the mapping logic for a single target attribute.
|
||||
// It implements driver.Valuer and sql.Scanner for JSON text column storage.
|
||||
type SpanMapperConfig struct {
|
||||
Sources []SpanMapperSource `json:"sources" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// SpanMapper is the domain model for a span attribute mapper.
|
||||
type SpanMapper struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
GroupID valuer.UUID `json:"group_id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
FieldContext FieldContext `json:"field_context" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
|
||||
type PostableSpanMapper struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
FieldContext FieldContext `json:"field_context" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdatableSpanMapper is the HTTP request body for updating a span mapper.
|
||||
// All fields are optional; only non-nil fields are applied.
|
||||
type UpdatableSpanMapper struct {
|
||||
FieldContext FieldContext `json:"field_context,omitempty"`
|
||||
Config *SpanMapperConfig `json:"config,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type GettableSpanMapper = SpanMapper
|
||||
|
||||
type GettableSpanMappers struct {
|
||||
Items []*GettableSpanMapper `json:"items" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func (FieldContext) Enum() []any {
|
||||
return []any{FieldContextSpanAttribute, FieldContextResource}
|
||||
}
|
||||
|
||||
func (SpanMapperOperation) Enum() []any {
|
||||
return []any{SpanMapperOperationMove, SpanMapperOperationCopy}
|
||||
}
|
||||
|
||||
func NewSpanMapperFromStorable(s *StorableSpanMapper) *SpanMapper {
|
||||
return &SpanMapper{
|
||||
TimeAuditable: s.TimeAuditable,
|
||||
UserAuditable: s.UserAuditable,
|
||||
ID: s.ID,
|
||||
GroupID: s.GroupID,
|
||||
Name: s.Name,
|
||||
FieldContext: s.FieldContext,
|
||||
Config: s.Config,
|
||||
Enabled: s.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSpanMapperFromPostable(req *PostableSpanMapper) *SpanMapper {
|
||||
return &SpanMapper{
|
||||
Name: req.Name,
|
||||
FieldContext: req.FieldContext,
|
||||
Config: req.Config,
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSpanMapperFromUpdatable(req *UpdatableSpanMapper) *SpanMapper {
|
||||
m := &SpanMapper{}
|
||||
if req.FieldContext != (FieldContext{}) {
|
||||
m.FieldContext = req.FieldContext
|
||||
}
|
||||
if req.Config != nil {
|
||||
m.Config = *req.Config
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
m.Enabled = *req.Enabled
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func NewSpanMappersFromStorableSpanMappers(ss []*StorableSpanMapper) []*SpanMapper {
|
||||
mappers := make([]*SpanMapper, len(ss))
|
||||
for i, s := range ss {
|
||||
mappers[i] = NewSpanMapperFromStorable(s)
|
||||
}
|
||||
return mappers
|
||||
}
|
||||
|
||||
func NewGettableSpanMappers(m []*SpanMapper) *GettableSpanMappers {
|
||||
return &GettableSpanMappers{Items: m}
|
||||
}
|
||||
109
pkg/types/spantypes/mappergroup.go
Normal file
109
pkg/types/spantypes/mappergroup.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeMappingGroupNotFound = errors.MustNewCode("span_attribute_mapping_group_not_found")
|
||||
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
|
||||
)
|
||||
|
||||
// SpanMapperGroupCategory defaults will be llm, tool, agent but user can configure more as they want.
|
||||
type SpanMapperGroupCategory valuer.String
|
||||
|
||||
// A group runs when any of the listed attribute/resource key patterns match.
|
||||
type SpanMapperGroupCondition struct {
|
||||
Attributes []string `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []string `json:"resource" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// SpanMapperGroup is the domain model for a span attribute mapping group.
|
||||
type SpanMapperGroup struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Category SpanMapperGroupCategory `json:"category" required:"true"`
|
||||
Condition SpanMapperGroupCondition `json:"condition" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
|
||||
// GettableSpanMapperGroup is the HTTP response representation of a mapping group.
|
||||
type GettableSpanMapperGroup = SpanMapperGroup
|
||||
|
||||
type PostableSpanMapperGroup struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Category SpanMapperGroupCategory `json:"category" required:"true"`
|
||||
Condition SpanMapperGroupCondition `json:"condition" required:"true"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdatableSpanMapperGroup is the HTTP request body for updating a mapping group.
|
||||
// All fields are optional; only non-nil fields are applied.
|
||||
type UpdatableSpanMapperGroup struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Condition *SpanMapperGroupCondition `json:"condition,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type ListSpanMapperGroupsQuery struct {
|
||||
Category *SpanMapperGroupCategory `query:"category"`
|
||||
Enabled *bool `query:"enabled"`
|
||||
}
|
||||
|
||||
type GettableSpanMapperGroups struct {
|
||||
Items []*GettableSpanMapperGroup `json:"items" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupFromStorable(s *StorableSpanMapperGroup) *SpanMapperGroup {
|
||||
return &SpanMapperGroup{
|
||||
TimeAuditable: s.TimeAuditable,
|
||||
UserAuditable: s.UserAuditable,
|
||||
ID: s.ID,
|
||||
OrgID: s.OrgID,
|
||||
Name: s.Name,
|
||||
Category: s.Category,
|
||||
Condition: s.Condition,
|
||||
Enabled: s.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupFromPostable(p *PostableSpanMapperGroup) *SpanMapperGroup {
|
||||
return &SpanMapperGroup{
|
||||
Name: p.Name,
|
||||
Category: p.Category,
|
||||
Condition: p.Condition,
|
||||
Enabled: p.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupFromUpdatable(u *UpdatableSpanMapperGroup) *SpanMapperGroup {
|
||||
g := &SpanMapperGroup{}
|
||||
if u.Name != nil {
|
||||
g.Name = *u.Name
|
||||
}
|
||||
if u.Condition != nil {
|
||||
g.Condition = *u.Condition
|
||||
}
|
||||
if u.Enabled != nil {
|
||||
g.Enabled = *u.Enabled
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupsFromStorableGroups(ss []*StorableSpanMapperGroup) []*SpanMapperGroup {
|
||||
groups := make([]*SpanMapperGroup, len(ss))
|
||||
for i, s := range ss {
|
||||
groups[i] = NewSpanMapperGroupFromStorable(s)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func NewGettableSpanMapperGroups(g []*SpanMapperGroup) *GettableSpanMapperGroups {
|
||||
return &GettableSpanMapperGroups{Items: g}
|
||||
}
|
||||
23
pkg/types/spantypes/processor_config.go
Normal file
23
pkg/types/spantypes/processor_config.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package spantypes
|
||||
|
||||
type SpanMappingProcessorConfig struct {
|
||||
Groups []SpanMappingGroup `yaml:"groups" json:"groups"`
|
||||
}
|
||||
|
||||
type SpanMappingGroup struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
ExistsAny SpanMappingExistsAny `yaml:"exists_any" json:"exists_any"`
|
||||
Attributes []SpanMappingAttribute `yaml:"attributes" json:"attributes"`
|
||||
}
|
||||
|
||||
type SpanMappingExistsAny struct {
|
||||
Attributes []string `yaml:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
Resource []string `yaml:"resource,omitempty" json:"resource,omitempty"`
|
||||
}
|
||||
|
||||
type SpanMappingAttribute struct {
|
||||
Target string `yaml:"target" json:"target"`
|
||||
Context string `yaml:"context,omitempty" json:"context,omitempty"`
|
||||
Action string `yaml:"action,omitempty" json:"action,omitempty"`
|
||||
Sources []string `yaml:"sources" json:"sources"`
|
||||
}
|
||||
87
pkg/types/spantypes/storable.go
Normal file
87
pkg/types/spantypes/storable.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type StorableSpanMapperGroup struct {
|
||||
bun.BaseModel `bun:"table:span_mapper_group,alias:span_attribute_mapping_group"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Category SpanMapperGroupCategory `bun:"category,type:text,notnull"`
|
||||
Condition SpanMapperGroupCondition `bun:"condition,type:jsonb,notnull"`
|
||||
Enabled bool `bun:"enabled,notnull,default:true"`
|
||||
}
|
||||
|
||||
type StorableSpanMapper struct {
|
||||
bun.BaseModel `bun:"table:span_mapper,alias:span_attribute_mapping"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
GroupID valuer.UUID `bun:"group_id,type:text,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
FieldContext FieldContext `bun:"field_context,type:text,notnull"`
|
||||
Config SpanMapperConfig `bun:"config,type:jsonb,notnull"`
|
||||
Enabled bool `bun:"enabled,notnull,default:true"`
|
||||
}
|
||||
|
||||
func (c SpanMapperGroupCondition) Value() (driver.Value, error) {
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (c *SpanMapperGroupCondition) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
case []byte:
|
||||
raw = v
|
||||
case nil:
|
||||
*c = SpanMapperGroupCondition{}
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInternalf(errors.CodeInternal, "spanmapper: cannot scan %T into Condition", src)
|
||||
}
|
||||
return json.Unmarshal(raw, c)
|
||||
}
|
||||
|
||||
func (m SpanMapperConfig) Value() (driver.Value, error) {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (m *SpanMapperConfig) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
case []byte:
|
||||
raw = v
|
||||
case nil:
|
||||
*m = SpanMapperConfig{}
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInternalf(errors.CodeInternal, "spanmapper: cannot scan %T into MapperConfig", src)
|
||||
}
|
||||
return json.Unmarshal(raw, m)
|
||||
}
|
||||
23
pkg/types/spantypes/store.go
Normal file
23
pkg/types/spantypes/store.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
// Group operations
|
||||
ListSpanMapperGroups(ctx context.Context, orgID valuer.UUID, q *ListSpanMapperGroupsQuery) ([]*StorableSpanMapperGroup, error)
|
||||
GetSpanMapperGroup(ctx context.Context, orgID, id valuer.UUID) (*StorableSpanMapperGroup, error)
|
||||
CreateSpanMapperGroup(ctx context.Context, group *StorableSpanMapperGroup) error
|
||||
UpdateSpanMapperGroup(ctx context.Context, group *StorableSpanMapperGroup) error
|
||||
DeleteSpanMapperGroup(ctx context.Context, orgID, id valuer.UUID) error
|
||||
|
||||
// Mapper operations
|
||||
ListSpanMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]*StorableSpanMapper, error)
|
||||
GetSpanMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (*StorableSpanMapper, error)
|
||||
CreateSpanMapper(ctx context.Context, mapper *StorableSpanMapper) error
|
||||
UpdateSpanMapper(ctx context.Context, mapper *StorableSpanMapper) error
|
||||
DeleteSpanMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
}
|
||||
Reference in New Issue
Block a user