Compare commits

..

5 Commits

Author SHA1 Message Date
Abhi Kumar
ca473e4c65 feat: added changes for synced tooltip modes 2026-05-04 21:37:42 +05:30
Abhi Kumar
35dfecb674 chore: pr review changes 2026-05-04 19:38:17 +05:30
Abhi Kumar
a554631e2f chore: moved to module css 2026-05-04 19:22:10 +05:30
Abhi Kumar
39b400101a Merge branch 'main' of https://github.com/SigNoz/signoz into feat/dashboard-preferences 2026-05-04 13:05:55 +05:30
Abhi Kumar
825a07c9fe feat: user dashboard preference 2026-05-01 16:43:40 +05:30
191 changed files with 5876 additions and 14143 deletions

View File

@@ -18,13 +18,11 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/meterreporter"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -111,9 +109,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
return signoz.NewAuditorProviderFactories()
},
func(_ context.Context, _ flagger.Flagger, _ licensing.Licensing, _ telemetrystore.TelemetryStore, _ sqlstore.SQLStore, _ organization.Getter, _ zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
return signoz.NewMeterReporterProviderFactories(), "noop"
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},

View File

@@ -17,14 +17,6 @@ import (
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/metercollector/baseplatformfeemetercollector"
"github.com/SigNoz/signoz/ee/metercollector/datapointcountmetercollector"
"github.com/SigNoz/signoz/ee/metercollector/datapointsizemetercollector"
"github.com/SigNoz/signoz/ee/metercollector/logcountmetercollector"
"github.com/SigNoz/signoz/ee/metercollector/logsizemetercollector"
"github.com/SigNoz/signoz/ee/metercollector/spancountmetercollector"
"github.com/SigNoz/signoz/ee/metercollector/spansizemetercollector"
"github.com/SigNoz/signoz/ee/meterreporter/httpmeterreporter"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
@@ -43,12 +35,9 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
pkgflagger "github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/meterreporter"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -68,10 +57,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -171,19 +157,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return factories
},
func(ctx context.Context, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
factories := signoz.NewMeterReporterProviderFactories()
if err := factories.Add(httpmeterreporter.NewFactory(newMeterCollectors(licensing, telemetryStore, sqlStore), licensing, telemetryStore, orgGetter, zeus)); err != nil {
panic(err)
}
evalCtx := featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})
if flagger.BooleanOrEmpty(ctx, pkgflagger.FeatureUseMeterReporter, evalCtx) {
return factories, "http"
}
return factories, "noop"
},
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)
@@ -243,15 +216,3 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil
}
func newMeterCollectors(licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) map[metercollectortypes.Name]metercollector.MeterCollector {
return map[metercollectortypes.Name]metercollector.MeterCollector{
baseplatformfeemetercollector.MeterName: baseplatformfeemetercollector.New(licensing),
logcountmetercollector.MeterName: logcountmetercollector.New(telemetryStore, sqlStore),
logsizemetercollector.MeterName: logsizemetercollector.New(telemetryStore, sqlStore),
datapointcountmetercollector.MeterName: datapointcountmetercollector.New(telemetryStore, sqlStore),
datapointsizemetercollector.MeterName: datapointsizemetercollector.New(telemetryStore, sqlStore),
spancountmetercollector.MeterName: spancountmetercollector.New(telemetryStore, sqlStore),
spansizemetercollector.MeterName: spansizemetercollector.New(telemetryStore, sqlStore),
}
}

View File

@@ -13,8 +13,6 @@ global:
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
# mcp_url: <unset>
# the url of the SigNoz AI Assistant server. when unset, the AI Assistant is hidden in the frontend.
# ai_assistant_url: <unset>
##################### Version #####################
version:
@@ -429,10 +427,3 @@ authz:
openfga:
# maximum tuples allowed per openfga write operation.
max_tuples_per_write: 100
##################### Meter Reporter #####################
meterreporter:
# The interval between collection ticks. Minimum 5m.
interval: 6h
# The per-tick timeout that bounds collect-and-ship work. Minimum 3m and must be less than interval.
timeout: 5m

View File

@@ -96,122 +96,6 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
- discord_configs
- required:
- email_configs
- required:
- incidentio_configs
- required:
- pagerduty_configs
- required:
- slack_configs
- required:
- webhook_configs
- required:
- opsgenie_configs
- required:
- wechat_configs
- required:
- pushover_configs
- required:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
- mattermost_configs
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
required:
- name
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -249,10 +133,6 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
@@ -265,15 +145,8 @@ components:
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
- saml
- email_password
- oidc
type: string
AuthtypesAuthNProviderInfo:
properties:
relayStatePath:
@@ -296,15 +169,11 @@ components:
AuthtypesCallbackAuthNSupport:
properties:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
url:
type: string
type: object
AuthtypesGettableAuthDomain:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
@@ -328,7 +197,7 @@ components:
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
updatedAt:
format: date-time
type: string
@@ -454,7 +323,7 @@ components:
AuthtypesPasswordAuthNSupport:
properties:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: string
type: object
AuthtypesPatchableObjects:
properties:
@@ -2494,9 +2363,6 @@ components:
type: object
GlobaltypesConfig:
properties:
ai_assistant_url:
nullable: true
type: string
external_url:
type: string
identN:
@@ -2510,7 +2376,6 @@ components:
- external_url
- ingestion_url
- mcp_url
- ai_assistant_url
type: object
GlobaltypesIdentNConfig:
properties:
@@ -5800,7 +5665,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
$ref: '#/components/schemas/ConfigReceiver'
responses:
"201":
content:
@@ -7177,63 +7042,6 @@ paths:
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain

View File

@@ -1,58 +0,0 @@
// Package baseplatformfeemetercollector collects the license-derived base platform fee meter.
package baseplatformfeemetercollector
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.base.platform.fee")
meterUnit = metercollectortypes.UnitCount
meterAggregation = metercollectortypes.AggregationMax
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects base platform fee meters.
type Provider struct {
licensing licensing.Licensing
}
func New(licensing licensing.Licensing) *Provider {
return &Provider{licensing: licensing}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect emits value 1 when the org has an active license.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
license, err := p.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "fetch active license for base platform fee meter")
}
if license == nil || license.Key == "" {
return nil, nil
}
return []meterreportertypes.Meter{
meterreportertypes.NewMeter(MeterName, 1, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
}),
}, nil
}

View File

@@ -1,107 +0,0 @@
package baseplatformfeemetercollector
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestCollectEmitsBasePlatformFeeMeterForValidLicense(t *testing.T) {
orgID := valuer.GenerateUUID()
window := completedWindow()
provider := New(&fakeLicensing{
license: &licensetypes.License{Key: "license-key"},
})
readings, err := provider.Collect(context.Background(), orgID, window)
require.NoError(t, err)
require.Equal(t, []meterreportertypes.Meter{
meterreportertypes.NewMeter(MeterName, 1, metercollectortypes.UnitCount, metercollectortypes.AggregationMax, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
}),
}, readings)
}
func TestCollectSkipsNilLicense(t *testing.T) {
readings, err := New(&fakeLicensing{}).Collect(context.Background(), valuer.GenerateUUID(), completedWindow())
require.NoError(t, err)
require.Empty(t, readings)
}
func TestProviderMetadata(t *testing.T) {
provider := New(&fakeLicensing{})
require.Equal(t, "signoz.meter.base.platform.fee", provider.Name().String())
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
require.Equal(t, metercollectortypes.AggregationMax, provider.Aggregation())
}
func TestCollectRejectsInvalidWindowBeforeLicensing(t *testing.T) {
readings, err := New(nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}
func completedWindow() meterreportertypes.Window {
start := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
return meterreportertypes.Window{
StartUnixMilli: start.UnixMilli(),
EndUnixMilli: start.AddDate(0, 0, 1).UnixMilli(),
IsCompleted: true,
}
}
var _ licensing.Licensing = (*fakeLicensing)(nil)
type fakeLicensing struct {
license *licensetypes.License
err error
}
func (f *fakeLicensing) Start(context.Context) error {
return nil
}
func (f *fakeLicensing) Stop(context.Context) error {
return nil
}
func (f *fakeLicensing) Validate(context.Context) error {
return nil
}
func (f *fakeLicensing) Activate(context.Context, valuer.UUID, string) error {
return nil
}
func (f *fakeLicensing) GetActive(context.Context, valuer.UUID) (*licensetypes.License, error) {
return f.license, f.err
}
func (f *fakeLicensing) Refresh(context.Context, valuer.UUID) error {
return nil
}
func (f *fakeLicensing) Checkout(context.Context, valuer.UUID, *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
return &licensetypes.GettableSubscription{}, nil
}
func (f *fakeLicensing) Portal(context.Context, valuer.UUID, *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
return &licensetypes.GettableSubscription{}, nil
}
func (f *fakeLicensing) GetFeatureFlags(context.Context, valuer.UUID) ([]*licensetypes.Feature, error) {
return nil, nil
}
func (f *fakeLicensing) Collect(context.Context, valuer.UUID) (map[string]any, error) {
return map[string]any{}, nil
}

View File

@@ -1,276 +0,0 @@
// Package datapointcountmetercollector collects metric datapoint count meters
// by workspace and retention. Keep the query local to this meter.
package datapointcountmetercollector
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/ee/metercollector/retention"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.metric.datapoint.count")
meterUnit = metercollectortypes.UnitCount
meterAggregation = metercollectortypes.AggregationSum
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects datapoint count meters.
type Provider struct {
telemetryStore telemetrystore.TelemetryStore
sqlStore sqlstore.SQLStore
}
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
return &Provider{
telemetryStore: telemetryStore,
sqlStore: sqlStore,
}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect aggregates datapoint count for the window and emits an empty-day sentinel.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
meterName := MeterName.String()
slices, err := retention.LoadActiveSlices(
ctx,
p.sqlStore,
orgID,
telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4LocalTableName,
retentiontypes.DefaultMetricsRetentionDays,
window.StartUnixMilli, window.EndUnixMilli,
)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
}
type bucket struct {
dimensions map[string]string
value float64
}
accumulator := make(map[string]*bucket)
for _, slice := range slices {
query, args, dimensionColumns, err := buildQuery(meterName, slice)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
}
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
if err := func() error {
defer rows.Close()
for rows.Next() {
dimensionValues := make([]string, len(dimensionColumns))
var retentionDays int32
var retentionRuleIndex int32
var value float64
scanDest := make([]any, 0, len(dimensionValues)+3)
for i := range dimensionValues {
scanDest = append(scanDest, &dimensionValues[i])
}
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
if err := rows.Scan(scanDest...); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
key := bucketKey(dimensions)
b, ok := accumulator[key]
if !ok {
b = &bucket{dimensions: dimensions}
accumulator[key] = b
}
b.value += value
}
if err := rows.Err(); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
return nil
}(); err != nil {
return nil, err
}
}
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
for _, b := range accumulator {
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
}
// Empty windows still emit a sentinel so checkpoints can advance.
if len(meters) == 0 && len(slices) > 0 {
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
}))
}
return meters, nil
}
// buildQuery stays local because each meter owns its billing query.
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
if err != nil {
return "", nil, nil, err
}
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
if err != nil {
return "", nil, nil, err
}
columns, err := dimensionColumnsFor(slice.Rules)
if err != nil {
return "", nil, nil, err
}
selects := make([]string, 0, len(columns)+3)
groupBy := make([]string, 0, len(columns)+2)
for _, column := range columns {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
groupBy = append(groupBy, column.alias)
}
selects = append(selects,
retentionExpr+" AS retention_days",
retentionRuleIndexExpr+" AS retention_rule_index",
"ifNull(sum(value), 0) AS value",
)
groupBy = append(groupBy, "retention_days", "retention_rule_index")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", slice.StartMs),
sb.LT("unix_milli", slice.EndMs),
)
sb.GroupBy(groupBy...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, columns, nil
}
type dimensionColumn struct {
key string
alias string
}
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
dimensionKeys, err := retention.RuleDimensionKeys(rules)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(dimensionKeys)+1)
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
for _, key := range dimensionKeys {
if key == metercollector.DimensionWorkspaceKeyID {
continue
}
keys = append(keys, key)
}
columns := make([]dimensionColumn, len(keys))
for i, key := range keys {
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
}
return columns, nil
}
func buildDimensions(
orgID valuer.UUID,
retentionDays int,
retentionRuleIndex int,
columns []dimensionColumn,
values []string,
rules []retentiontypes.CustomRetentionRule,
) (map[string]string, error) {
if len(columns) != len(values) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
}
valuesByKey := make(map[string]string, len(columns))
for i, column := range columns {
valuesByKey[column.key] = values[i]
}
dimensions := map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
}
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
if retentionRuleIndex < 0 {
return dimensions, nil
}
if retentionRuleIndex >= len(rules) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
}
for _, filter := range rules[retentionRuleIndex].Filters {
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
}
return dimensions, nil
}
func addNonEmpty(dimensions map[string]string, key, value string) {
if value == "" {
return
}
dimensions[key] = value
}
func bucketKey(dimensions map[string]string) string {
keys := make([]string, 0, len(dimensions))
for key := range dimensions {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
value := dimensions[key]
b.WriteString(strconv.Itoa(len(key)))
b.WriteByte(':')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte(';')
}
return b.String()
}

View File

@@ -1,67 +0,0 @@
package datapointcountmetercollector
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestBuildDimensions(t *testing.T) {
orgID := valuer.GenerateUUID()
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
columns := []dimensionColumn{
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
{key: "service.name", alias: "dim_1"},
}
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
require.NoError(t, err)
require.Equal(t, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
}, dimensions)
}
func TestProviderMetadata(t *testing.T) {
provider := New(nil, nil)
require.Equal(t, "signoz.meter.metric.datapoint.count", provider.Name().String())
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
}
func TestBucketKeyIsStable(t *testing.T) {
first := bucketKey(map[string]string{
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
})
second := bucketKey(map[string]string{
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
})
require.Equal(t, first, second)
require.NotEmpty(t, first)
}
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}

View File

@@ -1,276 +0,0 @@
// Package datapointsizemetercollector collects metric datapoint size meters
// by workspace and retention. Keep the query local to this meter.
package datapointsizemetercollector
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/ee/metercollector/retention"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.metric.datapoint.size")
meterUnit = metercollectortypes.UnitBytes
meterAggregation = metercollectortypes.AggregationSum
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects datapoint size meters.
type Provider struct {
telemetryStore telemetrystore.TelemetryStore
sqlStore sqlstore.SQLStore
}
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
return &Provider{
telemetryStore: telemetryStore,
sqlStore: sqlStore,
}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect aggregates datapoint size for the window and emits an empty-day sentinel.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
meterName := MeterName.String()
slices, err := retention.LoadActiveSlices(
ctx,
p.sqlStore,
orgID,
telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4LocalTableName,
retentiontypes.DefaultMetricsRetentionDays,
window.StartUnixMilli, window.EndUnixMilli,
)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
}
type bucket struct {
dimensions map[string]string
value float64
}
accumulator := make(map[string]*bucket)
for _, slice := range slices {
query, args, dimensionColumns, err := buildQuery(meterName, slice)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
}
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
if err := func() error {
defer rows.Close()
for rows.Next() {
dimensionValues := make([]string, len(dimensionColumns))
var retentionDays int32
var retentionRuleIndex int32
var value float64
scanDest := make([]any, 0, len(dimensionValues)+3)
for i := range dimensionValues {
scanDest = append(scanDest, &dimensionValues[i])
}
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
if err := rows.Scan(scanDest...); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
key := bucketKey(dimensions)
b, ok := accumulator[key]
if !ok {
b = &bucket{dimensions: dimensions}
accumulator[key] = b
}
b.value += value
}
if err := rows.Err(); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
return nil
}(); err != nil {
return nil, err
}
}
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
for _, b := range accumulator {
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
}
// Empty windows still emit a sentinel so checkpoints can advance.
if len(meters) == 0 && len(slices) > 0 {
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
}))
}
return meters, nil
}
// buildQuery stays local because each meter owns its billing query.
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
if err != nil {
return "", nil, nil, err
}
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
if err != nil {
return "", nil, nil, err
}
columns, err := dimensionColumnsFor(slice.Rules)
if err != nil {
return "", nil, nil, err
}
selects := make([]string, 0, len(columns)+3)
groupBy := make([]string, 0, len(columns)+2)
for _, column := range columns {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
groupBy = append(groupBy, column.alias)
}
selects = append(selects,
retentionExpr+" AS retention_days",
retentionRuleIndexExpr+" AS retention_rule_index",
"ifNull(sum(value), 0) AS value",
)
groupBy = append(groupBy, "retention_days", "retention_rule_index")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", slice.StartMs),
sb.LT("unix_milli", slice.EndMs),
)
sb.GroupBy(groupBy...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, columns, nil
}
type dimensionColumn struct {
key string
alias string
}
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
dimensionKeys, err := retention.RuleDimensionKeys(rules)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(dimensionKeys)+1)
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
for _, key := range dimensionKeys {
if key == metercollector.DimensionWorkspaceKeyID {
continue
}
keys = append(keys, key)
}
columns := make([]dimensionColumn, len(keys))
for i, key := range keys {
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
}
return columns, nil
}
func buildDimensions(
orgID valuer.UUID,
retentionDays int,
retentionRuleIndex int,
columns []dimensionColumn,
values []string,
rules []retentiontypes.CustomRetentionRule,
) (map[string]string, error) {
if len(columns) != len(values) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
}
valuesByKey := make(map[string]string, len(columns))
for i, column := range columns {
valuesByKey[column.key] = values[i]
}
dimensions := map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
}
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
if retentionRuleIndex < 0 {
return dimensions, nil
}
if retentionRuleIndex >= len(rules) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
}
for _, filter := range rules[retentionRuleIndex].Filters {
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
}
return dimensions, nil
}
func addNonEmpty(dimensions map[string]string, key, value string) {
if value == "" {
return
}
dimensions[key] = value
}
func bucketKey(dimensions map[string]string) string {
keys := make([]string, 0, len(dimensions))
for key := range dimensions {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
value := dimensions[key]
b.WriteString(strconv.Itoa(len(key)))
b.WriteByte(':')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte(';')
}
return b.String()
}

View File

@@ -1,67 +0,0 @@
package datapointsizemetercollector
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestBuildDimensions(t *testing.T) {
orgID := valuer.GenerateUUID()
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
columns := []dimensionColumn{
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
{key: "service.name", alias: "dim_1"},
}
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
require.NoError(t, err)
require.Equal(t, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
}, dimensions)
}
func TestProviderMetadata(t *testing.T) {
provider := New(nil, nil)
require.Equal(t, "signoz.meter.metric.datapoint.size", provider.Name().String())
require.Equal(t, metercollectortypes.UnitBytes, provider.Unit())
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
}
func TestBucketKeyIsStable(t *testing.T) {
first := bucketKey(map[string]string{
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
})
second := bucketKey(map[string]string{
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
})
require.Equal(t, first, second)
require.NotEmpty(t, first)
}
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}

View File

@@ -1,276 +0,0 @@
// Package logcountmetercollector collects log count meters by workspace and
// retention. Keep the query local to this meter.
package logcountmetercollector
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/ee/metercollector/retention"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.log.count")
meterUnit = metercollectortypes.UnitCount
meterAggregation = metercollectortypes.AggregationSum
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects log count meters.
type Provider struct {
telemetryStore telemetrystore.TelemetryStore
sqlStore sqlstore.SQLStore
}
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
return &Provider{
telemetryStore: telemetryStore,
sqlStore: sqlStore,
}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect aggregates log count for the window and emits an empty-day sentinel.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
meterName := MeterName.String()
slices, err := retention.LoadActiveSlices(
ctx,
p.sqlStore,
orgID,
telemetrylogs.DBName+"."+telemetrylogs.LogsV2LocalTableName,
retentiontypes.DefaultLogsRetentionDays,
window.StartUnixMilli, window.EndUnixMilli,
)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
}
type bucket struct {
dimensions map[string]string
value float64
}
accumulator := make(map[string]*bucket)
for _, slice := range slices {
query, args, dimensionColumns, err := buildQuery(meterName, slice)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
}
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
if err := func() error {
defer rows.Close()
for rows.Next() {
dimensionValues := make([]string, len(dimensionColumns))
var retentionDays int32
var retentionRuleIndex int32
var value float64
scanDest := make([]any, 0, len(dimensionValues)+3)
for i := range dimensionValues {
scanDest = append(scanDest, &dimensionValues[i])
}
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
if err := rows.Scan(scanDest...); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
key := bucketKey(dimensions)
b, ok := accumulator[key]
if !ok {
b = &bucket{dimensions: dimensions}
accumulator[key] = b
}
b.value += value
}
if err := rows.Err(); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
return nil
}(); err != nil {
return nil, err
}
}
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
for _, b := range accumulator {
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
}
// Empty windows still emit a sentinel so checkpoints can advance.
if len(meters) == 0 && len(slices) > 0 {
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
}))
}
return meters, nil
}
// buildQuery stays local because each meter owns its billing query.
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
if err != nil {
return "", nil, nil, err
}
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
if err != nil {
return "", nil, nil, err
}
columns, err := dimensionColumnsFor(slice.Rules)
if err != nil {
return "", nil, nil, err
}
selects := make([]string, 0, len(columns)+3)
groupBy := make([]string, 0, len(columns)+2)
for _, column := range columns {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
groupBy = append(groupBy, column.alias)
}
selects = append(selects,
retentionExpr+" AS retention_days",
retentionRuleIndexExpr+" AS retention_rule_index",
"ifNull(sum(value), 0) AS value",
)
groupBy = append(groupBy, "retention_days", "retention_rule_index")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", slice.StartMs),
sb.LT("unix_milli", slice.EndMs),
)
sb.GroupBy(groupBy...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, columns, nil
}
type dimensionColumn struct {
key string
alias string
}
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
dimensionKeys, err := retention.RuleDimensionKeys(rules)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(dimensionKeys)+1)
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
for _, key := range dimensionKeys {
if key == metercollector.DimensionWorkspaceKeyID {
continue
}
keys = append(keys, key)
}
columns := make([]dimensionColumn, len(keys))
for i, key := range keys {
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
}
return columns, nil
}
func buildDimensions(
orgID valuer.UUID,
retentionDays int,
retentionRuleIndex int,
columns []dimensionColumn,
values []string,
rules []retentiontypes.CustomRetentionRule,
) (map[string]string, error) {
if len(columns) != len(values) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
}
valuesByKey := make(map[string]string, len(columns))
for i, column := range columns {
valuesByKey[column.key] = values[i]
}
dimensions := map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
}
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
if retentionRuleIndex < 0 {
return dimensions, nil
}
if retentionRuleIndex >= len(rules) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
}
for _, filter := range rules[retentionRuleIndex].Filters {
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
}
return dimensions, nil
}
func addNonEmpty(dimensions map[string]string, key, value string) {
if value == "" {
return
}
dimensions[key] = value
}
func bucketKey(dimensions map[string]string) string {
keys := make([]string, 0, len(dimensions))
for key := range dimensions {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
value := dimensions[key]
b.WriteString(strconv.Itoa(len(key)))
b.WriteByte(':')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte(';')
}
return b.String()
}

View File

@@ -1,67 +0,0 @@
package logcountmetercollector
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestBuildDimensions(t *testing.T) {
orgID := valuer.GenerateUUID()
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
columns := []dimensionColumn{
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
{key: "service.name", alias: "dim_1"},
}
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
require.NoError(t, err)
require.Equal(t, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
}, dimensions)
}
func TestProviderMetadata(t *testing.T) {
provider := New(nil, nil)
require.Equal(t, "signoz.meter.log.count", provider.Name().String())
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
}
func TestBucketKeyIsStable(t *testing.T) {
first := bucketKey(map[string]string{
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
})
second := bucketKey(map[string]string{
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
})
require.Equal(t, first, second)
require.NotEmpty(t, first)
}
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}

View File

@@ -1,276 +0,0 @@
// Package logsizemetercollector collects log size meters by workspace and
// retention. Keep the query local to this meter.
package logsizemetercollector
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/ee/metercollector/retention"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.log.size")
meterUnit = metercollectortypes.UnitBytes
meterAggregation = metercollectortypes.AggregationSum
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects log size meters.
type Provider struct {
telemetryStore telemetrystore.TelemetryStore
sqlStore sqlstore.SQLStore
}
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
return &Provider{
telemetryStore: telemetryStore,
sqlStore: sqlStore,
}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect aggregates log size for the window and emits an empty-day sentinel.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
meterName := MeterName.String()
slices, err := retention.LoadActiveSlices(
ctx,
p.sqlStore,
orgID,
telemetrylogs.DBName+"."+telemetrylogs.LogsV2LocalTableName,
retentiontypes.DefaultLogsRetentionDays,
window.StartUnixMilli, window.EndUnixMilli,
)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
}
type bucket struct {
dimensions map[string]string
value float64
}
accumulator := make(map[string]*bucket)
for _, slice := range slices {
query, args, dimensionColumns, err := buildQuery(meterName, slice)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
}
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
if err := func() error {
defer rows.Close()
for rows.Next() {
dimensionValues := make([]string, len(dimensionColumns))
var retentionDays int32
var retentionRuleIndex int32
var value float64
scanDest := make([]any, 0, len(dimensionValues)+3)
for i := range dimensionValues {
scanDest = append(scanDest, &dimensionValues[i])
}
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
if err := rows.Scan(scanDest...); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
key := bucketKey(dimensions)
b, ok := accumulator[key]
if !ok {
b = &bucket{dimensions: dimensions}
accumulator[key] = b
}
b.value += value
}
if err := rows.Err(); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
return nil
}(); err != nil {
return nil, err
}
}
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
for _, b := range accumulator {
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
}
// Empty windows still emit a sentinel so checkpoints can advance.
if len(meters) == 0 && len(slices) > 0 {
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
}))
}
return meters, nil
}
// buildQuery stays local because each meter owns its billing query.
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
if err != nil {
return "", nil, nil, err
}
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
if err != nil {
return "", nil, nil, err
}
columns, err := dimensionColumnsFor(slice.Rules)
if err != nil {
return "", nil, nil, err
}
selects := make([]string, 0, len(columns)+3)
groupBy := make([]string, 0, len(columns)+2)
for _, column := range columns {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
groupBy = append(groupBy, column.alias)
}
selects = append(selects,
retentionExpr+" AS retention_days",
retentionRuleIndexExpr+" AS retention_rule_index",
"ifNull(sum(value), 0) AS value",
)
groupBy = append(groupBy, "retention_days", "retention_rule_index")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", slice.StartMs),
sb.LT("unix_milli", slice.EndMs),
)
sb.GroupBy(groupBy...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, columns, nil
}
type dimensionColumn struct {
key string
alias string
}
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
dimensionKeys, err := retention.RuleDimensionKeys(rules)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(dimensionKeys)+1)
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
for _, key := range dimensionKeys {
if key == metercollector.DimensionWorkspaceKeyID {
continue
}
keys = append(keys, key)
}
columns := make([]dimensionColumn, len(keys))
for i, key := range keys {
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
}
return columns, nil
}
func buildDimensions(
orgID valuer.UUID,
retentionDays int,
retentionRuleIndex int,
columns []dimensionColumn,
values []string,
rules []retentiontypes.CustomRetentionRule,
) (map[string]string, error) {
if len(columns) != len(values) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
}
valuesByKey := make(map[string]string, len(columns))
for i, column := range columns {
valuesByKey[column.key] = values[i]
}
dimensions := map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
}
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
if retentionRuleIndex < 0 {
return dimensions, nil
}
if retentionRuleIndex >= len(rules) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
}
for _, filter := range rules[retentionRuleIndex].Filters {
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
}
return dimensions, nil
}
func addNonEmpty(dimensions map[string]string, key, value string) {
if value == "" {
return
}
dimensions[key] = value
}
func bucketKey(dimensions map[string]string) string {
keys := make([]string, 0, len(dimensions))
for key := range dimensions {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
value := dimensions[key]
b.WriteString(strconv.Itoa(len(key)))
b.WriteByte(':')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte(';')
}
return b.String()
}

View File

@@ -1,67 +0,0 @@
package logsizemetercollector
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestBuildDimensions(t *testing.T) {
orgID := valuer.GenerateUUID()
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
columns := []dimensionColumn{
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
{key: "service.name", alias: "dim_1"},
}
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
require.NoError(t, err)
require.Equal(t, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
}, dimensions)
}
func TestProviderMetadata(t *testing.T) {
provider := New(nil, nil)
require.Equal(t, "signoz.meter.log.size", provider.Name().String())
require.Equal(t, metercollectortypes.UnitBytes, provider.Unit())
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
}
func TestBucketKeyIsStable(t *testing.T) {
first := bucketKey(map[string]string{
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
})
second := bucketKey(map[string]string{
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
})
require.Equal(t, first, second)
require.NotEmpty(t, first)
}
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}

View File

@@ -1,255 +0,0 @@
// Package retention builds retention slices and SQL expressions for meters.
// Collectors still own their table names, defaults, and aggregation queries.
package retention
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const secondsPerDay = 24 * 60 * 60
// These values are inlined into SQL, so keep the allowlist strict.
var (
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
labelValuePattern = regexp.MustCompile(`^[A-Za-z0-9_.\-:]+$`)
)
// LoadActiveSlices returns TTL slices covering [startMs, endMs).
// tableName must be fully qualified, for example "signoz_logs.logs_v2".
func LoadActiveSlices(
ctx context.Context,
sqlstore sqlstore.SQLStore,
orgID valuer.UUID,
tableName string,
fallbackDefaultDays int,
startMs, endMs int64,
) ([]retentiontypes.Slice, error) {
if startMs >= endMs {
return nil, nil
}
if sqlstore == nil {
return nil, errors.New(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "sqlstore is nil")
}
if tableName == "" {
return nil, errors.New(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "tableName is empty")
}
if fallbackDefaultDays <= 0 {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "non-positive fallbackDefaultDays %d", fallbackDefaultDays)
}
rows := []*retentiontypes.TTLSetting{}
err := sqlstore.
BunDB().
NewSelect().
Model(&rows).
Where("table_name = ?", tableName).
Where("org_id = ?", orgID.StringValue()).
Where("status = ?", retentiontypes.TTLSettingStatusSuccess).
Where("created_at < ?", time.UnixMilli(endMs).UTC()).
OrderExpr("created_at ASC").
Scan(ctx)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load ttl_setting rows for org %q table %q", orgID.StringValue(), tableName)
}
return buildSlicesFromRows(rows, fallbackDefaultDays, startMs, endMs)
}
func buildSlicesFromRows(rows []*retentiontypes.TTLSetting, fallbackDefaultDays int, startMs, endMs int64) ([]retentiontypes.Slice, error) {
if startMs >= endMs {
return nil, nil
}
// The latest row before the window is active at the window start.
var activeAtStart *retentiontypes.TTLSetting
inWindow := make([]*retentiontypes.TTLSetting, 0, len(rows))
for _, row := range rows {
rowMs := row.CreatedAt.UnixMilli()
if rowMs <= startMs {
activeAtStart = row
continue
}
if rowMs >= endMs {
continue
}
inWindow = append(inWindow, row)
}
activeRules, activeDefault, err := parseTTLSetting(activeAtStart, fallbackDefaultDays)
if err != nil {
return nil, err
}
slices := make([]retentiontypes.Slice, 0, len(inWindow)+1)
cursor := startMs
for _, row := range inWindow {
rowMs := row.CreatedAt.UnixMilli()
if rowMs <= cursor {
// Same-ms updates collapse: replace active config, no empty slice.
activeRules, activeDefault, err = parseTTLSetting(row, fallbackDefaultDays)
if err != nil {
return nil, err
}
continue
}
slices = append(slices, retentiontypes.Slice{
StartMs: cursor,
EndMs: rowMs,
Rules: activeRules,
DefaultDays: activeDefault,
})
cursor = rowMs
activeRules, activeDefault, err = parseTTLSetting(row, fallbackDefaultDays)
if err != nil {
return nil, err
}
}
if cursor < endMs {
slices = append(slices, retentiontypes.Slice{
StartMs: cursor,
EndMs: endMs,
Rules: activeRules,
DefaultDays: activeDefault,
})
}
return slices, nil
}
// parseTTLSetting returns rules and default days for one ttl_setting row.
func parseTTLSetting(row *retentiontypes.TTLSetting, fallbackDefaultDays int) ([]retentiontypes.CustomRetentionRule, int, error) {
if row == nil {
return nil, fallbackDefaultDays, nil
}
defaultDays := row.TTL
if row.Condition == "" {
// V1 stores seconds; round up to days.
defaultDays = (row.TTL + secondsPerDay - 1) / secondsPerDay
}
if defaultDays <= 0 {
defaultDays = fallbackDefaultDays
}
if row.Condition == "" {
return nil, defaultDays, nil
}
var rules []retentiontypes.CustomRetentionRule
if err := json.Unmarshal([]byte(row.Condition), &rules); err != nil {
return nil, 0, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "parse ttl_setting condition for row %q", row.ID.StringValue())
}
return rules, defaultDays, nil
}
// BuildMultiIfSQL renders the retention-days expression for one slice.
func BuildMultiIfSQL(rules []retentiontypes.CustomRetentionRule, defaultDays int) (string, error) {
if defaultDays <= 0 {
return "", errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "non-positive default retention %d", defaultDays)
}
if len(rules) == 0 {
return "toInt32(" + strconv.Itoa(defaultDays) + ")", nil
}
arms := make([]string, 0, 2*len(rules)+1)
for ruleIndex, rule := range rules {
if rule.TTLDays <= 0 {
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d has non-positive ttl_days %d", ruleIndex, rule.TTLDays)
}
conditionExpr, err := buildRuleConditionSQL(ruleIndex, rule)
if err != nil {
return "", err
}
arms = append(arms, conditionExpr)
arms = append(arms, strconv.Itoa(rule.TTLDays))
}
arms = append(arms, strconv.Itoa(defaultDays))
return "toInt32(multiIf(" + strings.Join(arms, ", ") + "))", nil
}
// BuildRuleIndexSQL renders the matched rule index, or -1 for fallback.
func BuildRuleIndexSQL(rules []retentiontypes.CustomRetentionRule) (string, error) {
if len(rules) == 0 {
return "toInt32(-1)", nil
}
arms := make([]string, 0, 2*len(rules)+1)
for ruleIndex, rule := range rules {
conditionExpr, err := buildRuleConditionSQL(ruleIndex, rule)
if err != nil {
return "", err
}
arms = append(arms, conditionExpr)
arms = append(arms, strconv.Itoa(ruleIndex))
}
arms = append(arms, "-1")
return "toInt32(multiIf(" + strings.Join(arms, ", ") + "))", nil
}
func buildRuleConditionSQL(ruleIndex int, rule retentiontypes.CustomRetentionRule) (string, error) {
if len(rule.Filters) == 0 {
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d has no filters", ruleIndex)
}
filterExprs := make([]string, 0, len(rule.Filters))
for filterIndex, filter := range rule.Filters {
if !labelKeyPattern.MatchString(filter.Key) {
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d has invalid key %q", ruleIndex, filterIndex, filter.Key)
}
if len(filter.Values) == 0 {
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d has no values", ruleIndex, filterIndex)
}
quoted := make([]string, len(filter.Values))
for valueIndex, value := range filter.Values {
if !labelValuePattern.MatchString(value) {
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d value %d is invalid %q", ruleIndex, filterIndex, valueIndex, value)
}
quoted[valueIndex] = "'" + value + "'"
}
filterExprs = append(filterExprs, fmt.Sprintf("JSONExtractString(labels, '%s') IN (%s)", filter.Key, strings.Join(quoted, ", ")))
}
return strings.Join(filterExprs, " AND "), nil
}
// RuleDimensionKeys returns unique label keys referenced by retention rules.
func RuleDimensionKeys(rules []retentiontypes.CustomRetentionRule) ([]string, error) {
keys := make([]string, 0)
seen := make(map[string]struct{})
for ruleIndex, rule := range rules {
for filterIndex, filter := range rule.Filters {
if !labelKeyPattern.MatchString(filter.Key) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d has invalid key %q", ruleIndex, filterIndex, filter.Key)
}
if _, ok := seen[filter.Key]; ok {
continue
}
seen[filter.Key] = struct{}{}
keys = append(keys, filter.Key)
}
}
return keys, nil
}

View File

@@ -1,153 +0,0 @@
package retention
import (
"encoding/json"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/stretchr/testify/require"
)
func TestBuildSlicesFromRows(t *testing.T) {
start := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 0, 1)
ruleA := retentiontypes.CustomRetentionRule{
Filters: []retentiontypes.FilterCondition{{Key: "service.name", Values: []string{"api"}}},
TTLDays: 7,
}
ruleB := retentiontypes.CustomRetentionRule{
Filters: []retentiontypes.FilterCondition{{Key: "env", Values: []string{"prod"}}},
TTLDays: 15,
}
t.Run("row before window is active at start", func(t *testing.T) {
slices, err := buildSlicesFromRows(
[]*retentiontypes.TTLSetting{
ttlSetting(t, start.Add(-time.Hour), 45, []retentiontypes.CustomRetentionRule{ruleA}),
},
30,
start.UnixMilli(),
end.UnixMilli(),
)
require.NoError(t, err)
require.Equal(t, []retentiontypes.Slice{{
StartMs: start.UnixMilli(),
EndMs: end.UnixMilli(),
Rules: []retentiontypes.CustomRetentionRule{ruleA},
DefaultDays: 45,
}}, slices)
})
t.Run("row inside window splits slices", func(t *testing.T) {
firstChange := start.Add(6 * time.Hour)
secondChange := start.Add(18 * time.Hour)
slices, err := buildSlicesFromRows(
[]*retentiontypes.TTLSetting{
ttlSetting(t, firstChange, 21, []retentiontypes.CustomRetentionRule{ruleA}),
ttlSetting(t, secondChange, 14, []retentiontypes.CustomRetentionRule{ruleB}),
},
30,
start.UnixMilli(),
end.UnixMilli(),
)
require.NoError(t, err)
require.Equal(t, []retentiontypes.Slice{
{
StartMs: start.UnixMilli(),
EndMs: firstChange.UnixMilli(),
DefaultDays: 30,
},
{
StartMs: firstChange.UnixMilli(),
EndMs: secondChange.UnixMilli(),
Rules: []retentiontypes.CustomRetentionRule{ruleA},
DefaultDays: 21,
},
{
StartMs: secondChange.UnixMilli(),
EndMs: end.UnixMilli(),
Rules: []retentiontypes.CustomRetentionRule{ruleB},
DefaultDays: 14,
},
}, slices)
})
t.Run("no rows uses fallback", func(t *testing.T) {
slices, err := buildSlicesFromRows(nil, 30, start.UnixMilli(), end.UnixMilli())
require.NoError(t, err)
require.Equal(t, []retentiontypes.Slice{{
StartMs: start.UnixMilli(),
EndMs: end.UnixMilli(),
DefaultDays: 30,
}}, slices)
})
}
func TestRetentionSQL(t *testing.T) {
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api", "worker"},
}},
TTLDays: 7,
}}
retentionSQL, err := BuildMultiIfSQL(rules, 30)
require.NoError(t, err)
require.Equal(t, "toInt32(multiIf(JSONExtractString(labels, 'service.name') IN ('api', 'worker'), 7, 30))", retentionSQL)
ruleIndexSQL, err := BuildRuleIndexSQL(rules)
require.NoError(t, err)
require.Equal(t, "toInt32(multiIf(JSONExtractString(labels, 'service.name') IN ('api', 'worker'), 0, -1))", ruleIndexSQL)
invalidRules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
_, err = BuildMultiIfSQL(invalidRules, 30)
require.Error(t, err)
_, err = BuildRuleIndexSQL(invalidRules)
require.Error(t, err)
}
func TestRuleDimensionKeysDedupes(t *testing.T) {
keys, err := RuleDimensionKeys([]retentiontypes.CustomRetentionRule{
{
Filters: []retentiontypes.FilterCondition{
{Key: "service.name", Values: []string{"api"}},
{Key: "env", Values: []string{"prod"}},
},
TTLDays: 7,
},
{
Filters: []retentiontypes.FilterCondition{
{Key: "service.name", Values: []string{"worker"}},
{Key: "cluster", Values: []string{"primary"}},
},
TTLDays: 15,
},
})
require.NoError(t, err)
require.Equal(t, []string{"service.name", "env", "cluster"}, keys)
}
func ttlSetting(t *testing.T, createdAt time.Time, ttlDays int, rules []retentiontypes.CustomRetentionRule) *retentiontypes.TTLSetting {
t.Helper()
condition, err := json.Marshal(rules)
require.NoError(t, err)
return &retentiontypes.TTLSetting{
CreatedAt: createdAt,
TTL: ttlDays,
Condition: string(condition),
}
}

View File

@@ -1,276 +0,0 @@
// Package spancountmetercollector collects span count meters by workspace and
// retention. Keep the query local to this meter.
package spancountmetercollector
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/ee/metercollector/retention"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.span.count")
meterUnit = metercollectortypes.UnitCount
meterAggregation = metercollectortypes.AggregationSum
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects span count meters.
type Provider struct {
telemetryStore telemetrystore.TelemetryStore
sqlStore sqlstore.SQLStore
}
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
return &Provider{
telemetryStore: telemetryStore,
sqlStore: sqlStore,
}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect aggregates span count for the window and emits an empty-day sentinel.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
meterName := MeterName.String()
slices, err := retention.LoadActiveSlices(
ctx,
p.sqlStore,
orgID,
telemetrytraces.DBName+"."+telemetrytraces.SpanIndexV3LocalTableName,
retentiontypes.DefaultTracesRetentionDays,
window.StartUnixMilli, window.EndUnixMilli,
)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
}
type bucket struct {
dimensions map[string]string
value float64
}
accumulator := make(map[string]*bucket)
for _, slice := range slices {
query, args, dimensionColumns, err := buildQuery(meterName, slice)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
}
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
if err := func() error {
defer rows.Close()
for rows.Next() {
dimensionValues := make([]string, len(dimensionColumns))
var retentionDays int32
var retentionRuleIndex int32
var value float64
scanDest := make([]any, 0, len(dimensionValues)+3)
for i := range dimensionValues {
scanDest = append(scanDest, &dimensionValues[i])
}
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
if err := rows.Scan(scanDest...); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
key := bucketKey(dimensions)
b, ok := accumulator[key]
if !ok {
b = &bucket{dimensions: dimensions}
accumulator[key] = b
}
b.value += value
}
if err := rows.Err(); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
return nil
}(); err != nil {
return nil, err
}
}
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
for _, b := range accumulator {
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
}
// Empty windows still emit a sentinel so checkpoints can advance.
if len(meters) == 0 && len(slices) > 0 {
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
}))
}
return meters, nil
}
// buildQuery stays local because each meter owns its billing query.
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
if err != nil {
return "", nil, nil, err
}
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
if err != nil {
return "", nil, nil, err
}
columns, err := dimensionColumnsFor(slice.Rules)
if err != nil {
return "", nil, nil, err
}
selects := make([]string, 0, len(columns)+3)
groupBy := make([]string, 0, len(columns)+2)
for _, column := range columns {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
groupBy = append(groupBy, column.alias)
}
selects = append(selects,
retentionExpr+" AS retention_days",
retentionRuleIndexExpr+" AS retention_rule_index",
"ifNull(sum(value), 0) AS value",
)
groupBy = append(groupBy, "retention_days", "retention_rule_index")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", slice.StartMs),
sb.LT("unix_milli", slice.EndMs),
)
sb.GroupBy(groupBy...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, columns, nil
}
type dimensionColumn struct {
key string
alias string
}
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
dimensionKeys, err := retention.RuleDimensionKeys(rules)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(dimensionKeys)+1)
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
for _, key := range dimensionKeys {
if key == metercollector.DimensionWorkspaceKeyID {
continue
}
keys = append(keys, key)
}
columns := make([]dimensionColumn, len(keys))
for i, key := range keys {
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
}
return columns, nil
}
func buildDimensions(
orgID valuer.UUID,
retentionDays int,
retentionRuleIndex int,
columns []dimensionColumn,
values []string,
rules []retentiontypes.CustomRetentionRule,
) (map[string]string, error) {
if len(columns) != len(values) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
}
valuesByKey := make(map[string]string, len(columns))
for i, column := range columns {
valuesByKey[column.key] = values[i]
}
dimensions := map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
}
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
if retentionRuleIndex < 0 {
return dimensions, nil
}
if retentionRuleIndex >= len(rules) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
}
for _, filter := range rules[retentionRuleIndex].Filters {
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
}
return dimensions, nil
}
func addNonEmpty(dimensions map[string]string, key, value string) {
if value == "" {
return
}
dimensions[key] = value
}
func bucketKey(dimensions map[string]string) string {
keys := make([]string, 0, len(dimensions))
for key := range dimensions {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
value := dimensions[key]
b.WriteString(strconv.Itoa(len(key)))
b.WriteByte(':')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte(';')
}
return b.String()
}

View File

@@ -1,67 +0,0 @@
package spancountmetercollector
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestBuildDimensions(t *testing.T) {
orgID := valuer.GenerateUUID()
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
columns := []dimensionColumn{
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
{key: "service.name", alias: "dim_1"},
}
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
require.NoError(t, err)
require.Equal(t, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
}, dimensions)
}
func TestProviderMetadata(t *testing.T) {
provider := New(nil, nil)
require.Equal(t, "signoz.meter.span.count", provider.Name().String())
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
}
func TestBucketKeyIsStable(t *testing.T) {
first := bucketKey(map[string]string{
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
})
second := bucketKey(map[string]string{
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
})
require.Equal(t, first, second)
require.NotEmpty(t, first)
}
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}

View File

@@ -1,276 +0,0 @@
// Package spansizemetercollector collects span size meters by workspace and
// retention. Keep the query local to this meter.
package spansizemetercollector
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/ee/metercollector/retention"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MeterName is the typed registry key for this collector.
var (
MeterName = metercollectortypes.MustNewName("signoz.meter.span.size")
meterUnit = metercollectortypes.UnitBytes
meterAggregation = metercollectortypes.AggregationSum
)
var _ metercollector.MeterCollector = (*Provider)(nil)
// Provider collects span size meters.
type Provider struct {
telemetryStore telemetrystore.TelemetryStore
sqlStore sqlstore.SQLStore
}
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
return &Provider{
telemetryStore: telemetryStore,
sqlStore: sqlStore,
}
}
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
return meterAggregation
}
// Collect aggregates span size for the window and emits an empty-day sentinel.
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
if !window.IsValid() {
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
}
meterName := MeterName.String()
slices, err := retention.LoadActiveSlices(
ctx,
p.sqlStore,
orgID,
telemetrytraces.DBName+"."+telemetrytraces.SpanIndexV3LocalTableName,
retentiontypes.DefaultTracesRetentionDays,
window.StartUnixMilli, window.EndUnixMilli,
)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
}
type bucket struct {
dimensions map[string]string
value float64
}
accumulator := make(map[string]*bucket)
for _, slice := range slices {
query, args, dimensionColumns, err := buildQuery(meterName, slice)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
}
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
if err := func() error {
defer rows.Close()
for rows.Next() {
dimensionValues := make([]string, len(dimensionColumns))
var retentionDays int32
var retentionRuleIndex int32
var value float64
scanDest := make([]any, 0, len(dimensionValues)+3)
for i := range dimensionValues {
scanDest = append(scanDest, &dimensionValues[i])
}
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
if err := rows.Scan(scanDest...); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
key := bucketKey(dimensions)
b, ok := accumulator[key]
if !ok {
b = &bucket{dimensions: dimensions}
accumulator[key] = b
}
b.value += value
}
if err := rows.Err(); err != nil {
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
}
return nil
}(); err != nil {
return nil, err
}
}
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
for _, b := range accumulator {
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
}
// Empty windows still emit a sentinel so checkpoints can advance.
if len(meters) == 0 && len(slices) > 0 {
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
}))
}
return meters, nil
}
// buildQuery stays local because each meter owns its billing query.
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
if err != nil {
return "", nil, nil, err
}
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
if err != nil {
return "", nil, nil, err
}
columns, err := dimensionColumnsFor(slice.Rules)
if err != nil {
return "", nil, nil, err
}
selects := make([]string, 0, len(columns)+3)
groupBy := make([]string, 0, len(columns)+2)
for _, column := range columns {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
groupBy = append(groupBy, column.alias)
}
selects = append(selects,
retentionExpr+" AS retention_days",
retentionRuleIndexExpr+" AS retention_rule_index",
"ifNull(sum(value), 0) AS value",
)
groupBy = append(groupBy, "retention_days", "retention_rule_index")
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", slice.StartMs),
sb.LT("unix_milli", slice.EndMs),
)
sb.GroupBy(groupBy...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, columns, nil
}
type dimensionColumn struct {
key string
alias string
}
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
dimensionKeys, err := retention.RuleDimensionKeys(rules)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(dimensionKeys)+1)
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
for _, key := range dimensionKeys {
if key == metercollector.DimensionWorkspaceKeyID {
continue
}
keys = append(keys, key)
}
columns := make([]dimensionColumn, len(keys))
for i, key := range keys {
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
}
return columns, nil
}
func buildDimensions(
orgID valuer.UUID,
retentionDays int,
retentionRuleIndex int,
columns []dimensionColumn,
values []string,
rules []retentiontypes.CustomRetentionRule,
) (map[string]string, error) {
if len(columns) != len(values) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
}
valuesByKey := make(map[string]string, len(columns))
for i, column := range columns {
valuesByKey[column.key] = values[i]
}
dimensions := map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
}
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
if retentionRuleIndex < 0 {
return dimensions, nil
}
if retentionRuleIndex >= len(rules) {
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
}
for _, filter := range rules[retentionRuleIndex].Filters {
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
}
return dimensions, nil
}
func addNonEmpty(dimensions map[string]string, key, value string) {
if value == "" {
return
}
dimensions[key] = value
}
func bucketKey(dimensions map[string]string) string {
keys := make([]string, 0, len(dimensions))
for key := range dimensions {
keys = append(keys, key)
}
sort.Strings(keys)
var b strings.Builder
for _, key := range keys {
value := dimensions[key]
b.WriteString(strconv.Itoa(len(key)))
b.WriteByte(':')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte(';')
}
return b.String()
}

View File

@@ -1,67 +0,0 @@
package spansizemetercollector
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestBuildDimensions(t *testing.T) {
orgID := valuer.GenerateUUID()
rules := []retentiontypes.CustomRetentionRule{{
Filters: []retentiontypes.FilterCondition{{
Key: "service.name",
Values: []string{"api"},
}},
TTLDays: 7,
}}
columns := []dimensionColumn{
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
{key: "service.name", alias: "dim_1"},
}
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
require.NoError(t, err)
require.Equal(t, map[string]string{
metercollector.DimensionOrganizationID: orgID.StringValue(),
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
}, dimensions)
}
func TestProviderMetadata(t *testing.T) {
provider := New(nil, nil)
require.Equal(t, "signoz.meter.span.size", provider.Name().String())
require.Equal(t, metercollectortypes.UnitBytes, provider.Unit())
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
}
func TestBucketKeyIsStable(t *testing.T) {
first := bucketKey(map[string]string{
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
metercollector.DimensionWorkspaceKeyID: "workspace-1",
})
second := bucketKey(map[string]string{
metercollector.DimensionWorkspaceKeyID: "workspace-1",
"service.name": "api",
metercollector.DimensionRetentionDays: "30",
})
require.Equal(t, first, second)
require.NotEmpty(t, first)
}
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
require.Error(t, err)
require.Nil(t, readings)
}

View File

@@ -1,630 +0,0 @@
package httpmeterreporter
import (
"context"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/meterreporter"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/huandu/go-sqlbuilder"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var _ factory.ServiceWithHealthy = (*Provider)(nil)
var errCodeReportFailed = errors.MustNewCode("meterreporter_report_failed")
const (
phaseSealed = "sealed"
phaseToday = "today"
attrPhase = "phase"
attrResult = "result"
attrMeterReporterProvider = "meterreporter.provider"
attrOrgID = "meterreporter.org_id"
attrOrgCount = "meterreporter.org_count"
attrMeter = "meterreporter.meter"
attrDate = "meterreporter.date"
attrReadings = "meterreporter.readings"
attrReadingsCollected = "meterreporter.readings_collected"
attrReadingsDropped = "meterreporter.readings_dropped"
attrWindowStartUnixMilli = "meterreporter.window_start_unix_milli"
attrWindowEndUnixMilli = "meterreporter.window_end_unix_milli"
attrWindowCompleted = "meterreporter.window_completed"
attrCatchupStart = "meterreporter.catchup_start"
attrCatchupEnd = "meterreporter.catchup_end"
attrDurationMs = "meterreporter.duration_ms"
attrDryRun = "meterreporter.dry_run"
attrIdempotencyKey = "meterreporter.idempotency_key"
resultSuccess = "success"
resultFailure = "failure"
providerName = "http"
)
// Provider collects registered meters and ships them to Zeus.
type Provider struct {
settings factory.ScopedProviderSettings
config meterreporter.Config
collectors []metercollector.MeterCollector
licensing licensing.Licensing
telemetryStore telemetrystore.TelemetryStore
orgGetter organization.Getter
zeus zeus.Zeus
healthyC chan struct{}
stopC chan struct{}
goroutinesWg sync.WaitGroup
metrics *reporterMetrics
}
// NewFactory registers the HTTP meter reporter.
func NewFactory(
collectors map[metercollectortypes.Name]metercollector.MeterCollector,
licensing licensing.Licensing,
telemetryStore telemetrystore.TelemetryStore,
orgGetter organization.Getter,
zeus zeus.Zeus,
) factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config] {
return factory.NewProviderFactory(
factory.MustNewName(providerName),
func(ctx context.Context, providerSettings factory.ProviderSettings, config meterreporter.Config) (meterreporter.Reporter, error) {
return newProvider(ctx, providerSettings, config, collectors, licensing, telemetryStore, orgGetter, zeus)
},
)
}
func newProvider(
_ context.Context,
providerSettings factory.ProviderSettings,
config meterreporter.Config,
collectors map[metercollectortypes.Name]metercollector.MeterCollector,
licensing licensing.Licensing,
telemetryStore telemetrystore.TelemetryStore,
orgGetter organization.Getter,
zeus zeus.Zeus,
) (*Provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/meterreporter/httpmeterreporter")
metrics, err := newReporterMetrics(settings.Meter())
if err != nil {
return nil, err
}
orderedCollectors, err := validateCollectors(collectors)
if err != nil {
return nil, err
}
return &Provider{
settings: settings,
config: config,
collectors: orderedCollectors,
licensing: licensing,
telemetryStore: telemetryStore,
orgGetter: orgGetter,
zeus: zeus,
healthyC: make(chan struct{}),
stopC: make(chan struct{}),
metrics: metrics,
}, nil
}
func validateCollectors(collectors map[metercollectortypes.Name]metercollector.MeterCollector) ([]metercollector.MeterCollector, error) {
ordered := make([]metercollector.MeterCollector, 0, len(collectors))
for name, collector := range collectors {
if name.IsZero() {
return nil, errors.New(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "empty meter name in collector registry")
}
if collector == nil {
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "nil collector for meter %q", name.String())
}
if collector.Name() != name {
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "registry key %q does not match collector.Name() %q", name.String(), collector.Name().String())
}
if collector.Unit().IsZero() {
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "meter %q has empty unit", name.String())
}
if collector.Aggregation().IsZero() {
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "meter %q has empty aggregation", name.String())
}
ordered = append(ordered, collector)
}
sort.Slice(ordered, func(i, j int) bool {
return ordered[i].Name().String() < ordered[j].Name().String()
})
return ordered, nil
}
// Start runs an immediate tick, then repeats on Config.Interval.
func (provider *Provider) Start(ctx context.Context) error {
close(provider.healthyC)
provider.settings.Logger().InfoContext(ctx, "meter reporter started",
slog.Duration("interval", provider.config.Interval),
slog.Duration("timeout", provider.config.Timeout),
slog.Int("catchup_max_days_per_tick", provider.config.CatchupMaxDaysPerTick),
slog.Int("meters", len(provider.collectors)),
)
provider.goroutinesWg.Add(1)
go func() {
defer provider.goroutinesWg.Done()
provider.runTick(ctx)
ticker := time.NewTicker(provider.config.Interval)
defer ticker.Stop()
for {
select {
case <-provider.stopC:
return
case <-ticker.C:
provider.runTick(ctx)
}
}
}()
provider.goroutinesWg.Wait()
return nil
}
// Stop signals the tick loop and waits for any in-flight tick.
func (provider *Provider) Stop(ctx context.Context) error {
<-provider.healthyC
provider.settings.Logger().InfoContext(ctx, "meter reporter stopping")
select {
case <-provider.stopC:
// already closed
default:
close(provider.stopC)
}
provider.goroutinesWg.Wait()
provider.settings.Logger().InfoContext(ctx, "meter reporter stopped")
return nil
}
func (provider *Provider) Healthy() <-chan struct{} {
return provider.healthyC
}
// runTick executes one collect-and-ship cycle under Config.Timeout.
func (provider *Provider) runTick(parentCtx context.Context) {
tickStart := time.Now()
ctx, span := provider.settings.Tracer().Start(parentCtx, "meterreporter.Tick", trace.WithAttributes(
attribute.String(attrMeterReporterProvider, providerName),
attribute.Int("meterreporter.meters", len(provider.collectors)),
attribute.Int("meterreporter.catchup_max_days_per_tick", provider.config.CatchupMaxDaysPerTick),
))
defer span.End()
provider.metrics.ticks.Add(ctx, 1)
ctx, cancel := context.WithTimeout(ctx, provider.config.Timeout)
defer cancel()
provider.settings.Logger().DebugContext(ctx, "meter reporter tick started",
slog.Duration("timeout", provider.config.Timeout),
slog.Int("meters", len(provider.collectors)),
)
if err := provider.tick(ctx); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
span.SetAttributes(
attribute.String(attrResult, resultFailure),
attribute.Int64(attrDurationMs, time.Since(tickStart).Milliseconds()),
)
provider.settings.Logger().ErrorContext(ctx, "meter reporter tick failed",
errors.Attr(err),
slog.Duration("timeout", provider.config.Timeout),
slog.Duration("duration", time.Since(tickStart)),
)
return
}
span.SetAttributes(
attribute.String(attrResult, resultSuccess),
attribute.Int64(attrDurationMs, time.Since(tickStart).Milliseconds()),
)
provider.settings.Logger().DebugContext(ctx, "meter reporter tick completed", slog.Duration("duration", time.Since(tickStart)))
}
// tick processes sealed catchup days, then today's partial window.
func (provider *Provider) tick(ctx context.Context) error {
now := time.Now().UTC()
// Use one timestamp so a tick cannot straddle midnight.
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
yesterday := todayStart.AddDate(0, 0, -1)
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "failed to list organizations")
}
trace.SpanFromContext(ctx).SetAttributes(attribute.Int(attrOrgCount, len(orgs)))
if len(orgs) == 0 {
provider.settings.Logger().InfoContext(ctx, "skipping meter reporter tick; no organizations found")
return nil
}
org := orgs[0]
if len(orgs) > 1 {
// signoz_meter samples have no org marker.
provider.settings.Logger().WarnContext(ctx, "multiple orgs on a single instance; reporting only the first",
slog.Int("org_count", len(orgs)),
slog.String("selected_org_id", org.ID.StringValue()),
)
}
trace.SpanFromContext(ctx).SetAttributes(attribute.String(attrOrgID, org.ID.StringValue()))
license, err := provider.licensing.GetActive(ctx, org.ID)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "failed to fetch active license for org %q", org.ID.StringValue())
}
if license == nil || license.Key == "" {
provider.settings.Logger().WarnContext(ctx, "skipping tick, nil/empty license for org", slog.String("org_id", org.ID.StringValue()))
return nil
}
// TODO: re-enable once /v2/meters/checkpoints is live in staging. Until
// then we run with an empty checkpoint map; bootstrap floors are taken
// from data and dropCheckpointed becomes a no-op for the sealed window.
// checkpoints, err := provider.zeus.GetMeterCheckpoints(ctx, license.Key)
// if err != nil {
// provider.metrics.checkpointErrors.Add(ctx, 1)
// provider.settings.Logger().ErrorContext(ctx, "skipping tick: meter checkpoints call failed", errors.Attr(err))
// return nil
// }
// checkpointsByMeter := make(map[string]time.Time, len(checkpoints))
// for _, checkpoint := range checkpoints {
// checkpointsByMeter[checkpoint.Name] = checkpoint.Checkpoint.UTC()
// }
checkpointsByMeter := make(map[string]time.Time)
floor := provider.dataFloor(ctx, todayStart)
catchupStart := provider.catchupStart(floor, todayStart, checkpointsByMeter)
end := catchupStart.AddDate(0, 0, provider.config.CatchupMaxDaysPerTick-1)
if end.After(yesterday) {
end = yesterday
}
trace.SpanFromContext(ctx).SetAttributes(
attribute.String(attrCatchupStart, catchupStart.Format("2006-01-02")),
attribute.String(attrCatchupEnd, end.Format("2006-01-02")),
)
provider.settings.Logger().DebugContext(ctx, "meter reporter catchup window selected",
slog.String("org_id", org.ID.StringValue()),
slog.Time("data_floor", floor),
slog.Time("catchup_start", catchupStart),
slog.Time("catchup_end", end),
slog.Int("catchup_max_days_per_tick", provider.config.CatchupMaxDaysPerTick),
)
for day := catchupStart; !day.After(end); day = day.AddDate(0, 0, 1) {
window := meterreportertypes.Window{
StartUnixMilli: day.UnixMilli(),
EndUnixMilli: day.AddDate(0, 0, 1).UnixMilli(),
IsCompleted: true,
}
err := provider.runPhase(ctx, org.ID, license.Key, window, checkpointsByMeter)
result := resultSuccess
if err != nil {
result = resultFailure
}
provider.metrics.catchupDaysProcessed.Add(ctx, 1, metric.WithAttributes(attribute.String(attrResult, result)))
if err != nil {
provider.settings.Logger().WarnContext(ctx, "stopping sealed catchup after failed day",
errors.Attr(err),
slog.String("date", day.Format("2006-01-02")),
)
break
}
}
// Today's partial window runs every tick.
todayWindow := meterreportertypes.Window{
StartUnixMilli: todayStart.UnixMilli(),
EndUnixMilli: now.UnixMilli(),
IsCompleted: false,
}
_ = provider.runPhase(ctx, org.ID, license.Key, todayWindow, checkpointsByMeter)
return nil
}
// runPhase collects all meters for one window and ships the batch.
func (provider *Provider) runPhase(ctx context.Context, orgID valuer.UUID, licenseKey string, window meterreportertypes.Window, checkpointsByMeter map[string]time.Time) error {
phaseLabel := phaseToday
if window.IsCompleted {
phaseLabel = phaseSealed
}
phaseAttr := metric.WithAttributes(attribute.String(attrPhase, phaseLabel))
date := time.UnixMilli(window.StartUnixMilli).UTC().Format("2006-01-02")
phaseStart := time.Now()
ctx, span := provider.settings.Tracer().Start(ctx, "meterreporter.RunPhase", trace.WithAttributes(
attribute.String(attrPhase, phaseLabel),
attribute.String(attrOrgID, orgID.StringValue()),
attribute.String(attrDate, date),
attribute.Int64(attrWindowStartUnixMilli, window.StartUnixMilli),
attribute.Int64(attrWindowEndUnixMilli, window.EndUnixMilli),
attribute.Bool(attrWindowCompleted, window.IsCompleted),
))
defer span.End()
provider.settings.Logger().DebugContext(ctx, "meter reporter phase started",
slog.String("org_id", orgID.StringValue()),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Int64("start_unix_milli", window.StartUnixMilli),
slog.Int64("end_unix_milli", window.EndUnixMilli),
slog.Int("meters", len(provider.collectors)),
)
collectStart := time.Now()
readings := make([]meterreportertypes.Meter, 0, len(provider.collectors))
for _, collector := range provider.collectors {
meterName := collector.Name().String()
collectStart := time.Now()
collectCtx, collectSpan := provider.settings.Tracer().Start(ctx, "meterreporter.CollectMeter", trace.WithAttributes(
attribute.String(attrPhase, phaseLabel),
attribute.String(attrOrgID, orgID.StringValue()),
attribute.String(attrMeter, meterName),
attribute.String(attrDate, date),
attribute.Int64(attrWindowStartUnixMilli, window.StartUnixMilli),
attribute.Int64(attrWindowEndUnixMilli, window.EndUnixMilli),
attribute.Bool(attrWindowCompleted, window.IsCompleted),
))
collectedReadings, err := collector.Collect(collectCtx, orgID, window)
if err != nil {
collectSpan.RecordError(err)
collectSpan.SetStatus(codes.Error, err.Error())
collectSpan.SetAttributes(
attribute.String(attrResult, resultFailure),
attribute.Int64(attrDurationMs, time.Since(collectStart).Milliseconds()),
)
collectSpan.End()
provider.metrics.collectErrors.Add(ctx, 1, phaseAttr)
provider.settings.Logger().WarnContext(ctx, "meter collection failed",
errors.Attr(err),
slog.String("meter", meterName),
slog.String("org_id", orgID.StringValue()),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Duration("duration", time.Since(collectStart)),
)
continue
}
collectSpan.SetAttributes(
attribute.String(attrResult, resultSuccess),
attribute.Int(attrReadings, len(collectedReadings)),
attribute.Int64(attrDurationMs, time.Since(collectStart).Milliseconds()),
)
collectSpan.End()
provider.settings.Logger().DebugContext(ctx, "meter collection completed",
slog.String("meter", meterName),
slog.String("org_id", orgID.StringValue()),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Int("readings", len(collectedReadings)),
slog.Duration("duration", time.Since(collectStart)),
)
readings = append(readings, collectedReadings...)
}
collectDuration := time.Since(collectStart)
provider.metrics.collectDuration.Add(ctx, collectDuration.Seconds(), phaseAttr)
provider.metrics.collectOperations.Add(ctx, 1, phaseAttr)
span.SetAttributes(attribute.Int(attrReadingsCollected, len(readings)))
if window.IsCompleted {
beforeDrop := len(readings)
readings = dropCheckpointed(readings, time.UnixMilli(window.StartUnixMilli).UTC(), checkpointsByMeter)
dropped := beforeDrop - len(readings)
span.SetAttributes(attribute.Int(attrReadingsDropped, dropped))
if dropped > 0 {
provider.settings.Logger().DebugContext(ctx, "dropped checkpointed meter readings",
slog.String("org_id", orgID.StringValue()),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Int("dropped", dropped),
slog.Int("remaining", len(readings)),
)
}
}
if len(readings) == 0 {
span.SetAttributes(
attribute.String(attrResult, resultSuccess),
attribute.Int(attrReadings, 0),
attribute.Int64(attrDurationMs, time.Since(phaseStart).Milliseconds()),
)
provider.settings.Logger().DebugContext(ctx, "meter reporter phase produced no readings",
slog.String("org_id", orgID.StringValue()),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Duration("collect_duration", collectDuration),
slog.Duration("duration", time.Since(phaseStart)),
)
return nil
}
shipStart := time.Now()
err := provider.shipReadings(ctx, licenseKey, date, readings)
shipDuration := time.Since(shipStart)
provider.metrics.shipDuration.Add(ctx, shipDuration.Seconds(), phaseAttr)
provider.metrics.shipOperations.Add(ctx, 1, phaseAttr)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
span.SetAttributes(attribute.String(attrResult, resultFailure))
provider.metrics.postErrors.Add(ctx, 1, phaseAttr)
provider.settings.Logger().ErrorContext(ctx, "failed to ship meter readings",
errors.Attr(err),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Int("readings", len(readings)),
slog.Duration("ship_duration", shipDuration),
)
return err
}
provider.metrics.readingsEmitted.Add(ctx, int64(len(readings)), phaseAttr)
span.SetAttributes(
attribute.String(attrResult, resultSuccess),
attribute.Int(attrReadings, len(readings)),
attribute.Int64(attrDurationMs, time.Since(phaseStart).Milliseconds()),
)
provider.settings.Logger().InfoContext(ctx, "meter reporter phase shipped",
slog.String("org_id", orgID.StringValue()),
slog.String("phase", phaseLabel),
slog.String("date", date),
slog.Int("readings", len(readings)),
slog.Duration("collect_duration", collectDuration),
slog.Duration("ship_duration", shipDuration),
slog.Duration("duration", time.Since(phaseStart)),
)
return nil
}
// dropCheckpointed removes readings already covered by meter checkpoints.
func dropCheckpointed(readings []meterreportertypes.Meter, windowDay time.Time, checkpointsByMeter map[string]time.Time) []meterreportertypes.Meter {
if len(checkpointsByMeter) == 0 {
return readings
}
kept := readings[:0]
for _, reading := range readings {
checkpoint, ok := checkpointsByMeter[reading.MeterName]
if !ok || checkpoint.Before(windowDay) {
kept = append(kept, reading)
}
}
return kept
}
// catchupStart returns the earliest UTC day that still needs sealed reporting.
func (provider *Provider) catchupStart(floor time.Time, todayStart time.Time, checkpointsByMeter map[string]time.Time) time.Time {
catchupStart := todayStart
for _, collector := range provider.collectors {
next := floor
if checkpoint, ok := checkpointsByMeter[collector.Name().String()]; ok {
next = checkpoint.AddDate(0, 0, 1)
if next.Before(floor) {
next = floor
}
}
if next.Before(catchupStart) {
catchupStart = next
}
}
yesterday := todayStart.AddDate(0, 0, -1)
if catchupStart.After(yesterday) {
catchupStart = yesterday
}
return catchupStart
}
// dataFloor returns the earliest signoz_meter sample day, or today on failure.
func (provider *Provider) dataFloor(ctx context.Context, todayStart time.Time) time.Time {
ctx, span := provider.settings.Tracer().Start(ctx, "meterreporter.DataFloor")
defer span.End()
if provider.telemetryStore == nil {
span.SetAttributes(attribute.String(attrResult, resultSuccess))
return todayStart
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("ifNull(min(unix_milli), 0)")
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var minMs int64
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&minMs); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
span.SetAttributes(attribute.String(attrResult, resultFailure))
provider.settings.Logger().WarnContext(ctx, "failed to read data floor; falling back to latest sealed day", errors.Attr(err))
return todayStart
}
if minMs == 0 {
span.SetAttributes(
attribute.String(attrResult, resultSuccess),
attribute.Int64("meterreporter.data_floor_unix_milli", 0),
)
return todayStart
}
minDay := time.UnixMilli(minMs).UTC()
floor := time.Date(minDay.Year(), minDay.Month(), minDay.Day(), 0, 0, 0, 0, time.UTC)
span.SetAttributes(
attribute.String(attrResult, resultSuccess),
attribute.Int64("meterreporter.data_floor_unix_milli", floor.UnixMilli()),
)
provider.settings.Logger().DebugContext(ctx, "meter reporter data floor loaded", slog.Time("data_floor", floor))
return floor
}
// shipReadings sends one day's meter batch to Zeus.
func (provider *Provider) shipReadings(ctx context.Context, licenseKey string, date string, readings []meterreportertypes.Meter) error {
idempotencyKey := fmt.Sprintf("meter-cron:%s", date)
ctx, span := provider.settings.Tracer().Start(ctx, "meterreporter.ShipReadings", trace.WithAttributes(
attribute.String(attrDate, date),
attribute.Int(attrReadings, len(readings)),
attribute.String(attrIdempotencyKey, idempotencyKey),
attribute.Bool(attrDryRun, true),
))
defer span.End()
provider.settings.Logger().InfoContext(ctx, "meter readings prepared for shipment",
slog.String("date", date),
slog.Int("readings", len(readings)),
slog.String("idempotency_key", idempotencyKey),
slog.Bool("dry_run", true),
)
// Temporary visibility while /v2/meters is offline.
for _, reading := range readings {
provider.settings.Logger().InfoContext(ctx, "meter reading prepared for shipment",
slog.String("meter", reading.MeterName),
slog.Float64("value", reading.Value),
slog.String("unit", reading.Unit.StringValue()),
slog.String("aggregation", reading.Aggregation.StringValue()),
slog.Int64("start_unix_milli", reading.StartUnixMilli),
slog.Int64("end_unix_milli", reading.EndUnixMilli),
slog.Bool("is_completed", reading.IsCompleted),
slog.Any("dimensions", reading.Dimensions),
slog.String("idempotency_key", idempotencyKey),
)
}
// TODO: re-enable once /v2/meters is live in staging.
// body, err := json.Marshal(meterreportertypes.PostableMeters{Meters: readings})
// if err != nil {
// return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "marshal meter readings for %s", date)
// }
// if err := provider.zeus.PutMetersV3(ctx, licenseKey, idempotencyKey, body); err != nil {
// return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "ship meter readings for %s", date)
// }
_ = licenseKey
span.SetAttributes(attribute.String(attrResult, resultSuccess))
return nil
}

View File

@@ -1,108 +0,0 @@
package httpmeterreporter
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestValidateCollectorsRejectsBadRegistry(t *testing.T) {
meterA := metercollectortypes.MustNewName("signoz.test.a")
meterB := metercollectortypes.MustNewName("signoz.test.b")
t.Run("key name mismatch", func(t *testing.T) {
_, err := validateCollectors(map[metercollectortypes.Name]metercollector.MeterCollector{
meterA: testCollector{name: meterB},
})
require.Error(t, err)
})
t.Run("nil collector", func(t *testing.T) {
_, err := validateCollectors(map[metercollectortypes.Name]metercollector.MeterCollector{
meterA: nil,
})
require.Error(t, err)
})
}
func TestDropCheckpointed(t *testing.T) {
meterA := metercollectortypes.MustNewName("signoz.test.a")
meterB := metercollectortypes.MustNewName("signoz.test.b")
meterC := metercollectortypes.MustNewName("signoz.test.c")
windowDay := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
window := meterreportertypes.Window{
StartUnixMilli: windowDay.UnixMilli(),
EndUnixMilli: windowDay.AddDate(0, 0, 1).UnixMilli(),
IsCompleted: true,
}
readings := []meterreportertypes.Meter{
meterreportertypes.NewMeter(meterA, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
meterreportertypes.NewMeter(meterB, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
meterreportertypes.NewMeter(meterC, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
}
kept := dropCheckpointed(readings, windowDay, map[string]time.Time{
meterA.String(): windowDay,
meterB.String(): windowDay.AddDate(0, 0, -1),
})
require.Equal(t, []meterreportertypes.Meter{
meterreportertypes.NewMeter(meterB, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
meterreportertypes.NewMeter(meterC, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
}, kept)
}
func TestCatchupStart(t *testing.T) {
meterA := metercollectortypes.MustNewName("signoz.test.a")
floor := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
todayStart := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
provider := &Provider{
collectors: []metercollector.MeterCollector{
testCollector{name: meterA},
},
}
t.Run("no checkpoint starts at floor", func(t *testing.T) {
require.Equal(t, floor, provider.catchupStart(floor, todayStart, nil))
})
t.Run("checkpoint advances by one day", func(t *testing.T) {
require.Equal(t, floor.AddDate(0, 0, 2), provider.catchupStart(floor, todayStart, map[string]time.Time{
meterA.String(): floor.AddDate(0, 0, 1),
}))
})
}
type testCollector struct {
name metercollectortypes.Name
unit metercollectortypes.Unit
aggregation metercollectortypes.Aggregation
}
func (c testCollector) Name() metercollectortypes.Name {
return c.name
}
func (c testCollector) Unit() metercollectortypes.Unit {
if c.unit.IsZero() {
return metercollectortypes.UnitCount
}
return c.unit
}
func (c testCollector) Aggregation() metercollectortypes.Aggregation {
if c.aggregation.IsZero() {
return metercollectortypes.AggregationSum
}
return c.aggregation
}
func (c testCollector) Collect(context.Context, valuer.UUID, meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
return nil, nil
}

View File

@@ -1,90 +0,0 @@
package httpmeterreporter
import (
"github.com/SigNoz/signoz/pkg/errors"
"go.opentelemetry.io/otel/metric"
)
type reporterMetrics struct {
ticks metric.Int64Counter
readingsEmitted metric.Int64Counter
collectErrors metric.Int64Counter
postErrors metric.Int64Counter
checkpointErrors metric.Int64Counter
catchupDaysProcessed metric.Int64Counter
collectDuration metric.Float64Counter
collectOperations metric.Int64Counter
shipDuration metric.Float64Counter
shipOperations metric.Int64Counter
}
func newReporterMetrics(meter metric.Meter) (*reporterMetrics, error) {
var errs error
ticks, err := meter.Int64Counter("signoz.meterreporter.ticks", metric.WithDescription("Meter reporter ticks."))
if err != nil {
errs = errors.Join(errs, err)
}
readingsEmitted, err := meter.Int64Counter("signoz.meterreporter.readings.emitted", metric.WithDescription("Meter readings shipped to Zeus."))
if err != nil {
errs = errors.Join(errs, err)
}
collectErrors, err := meter.Int64Counter("signoz.meterreporter.collect.errors", metric.WithDescription("Meter collection errors."))
if err != nil {
errs = errors.Join(errs, err)
}
postErrors, err := meter.Int64Counter("signoz.meterreporter.post.errors", metric.WithDescription("Zeus POST failures."))
if err != nil {
errs = errors.Join(errs, err)
}
checkpointErrors, err := meter.Int64Counter("signoz.meterreporter.checkpoint.errors", metric.WithDescription("Zeus checkpoint read failures."))
if err != nil {
errs = errors.Join(errs, err)
}
catchupDaysProcessed, err := meter.Int64Counter("signoz.meterreporter.catchup.days_processed", metric.WithDescription("Sealed catchup days processed."))
if err != nil {
errs = errors.Join(errs, err)
}
collectDuration, err := meter.Float64Counter("signoz.meterreporter.collect.duration.seconds", metric.WithDescription("Cumulative collection duration."), metric.WithUnit("s"))
if err != nil {
errs = errors.Join(errs, err)
}
collectOperations, err := meter.Int64Counter("signoz.meterreporter.collect.operations", metric.WithDescription("Collection phases measured."))
if err != nil {
errs = errors.Join(errs, err)
}
shipDuration, err := meter.Float64Counter("signoz.meterreporter.ship.duration.seconds", metric.WithDescription("Cumulative ship duration."), metric.WithUnit("s"))
if err != nil {
errs = errors.Join(errs, err)
}
shipOperations, err := meter.Int64Counter("signoz.meterreporter.ship.operations", metric.WithDescription("Ship phases measured."))
if err != nil {
errs = errors.Join(errs, err)
}
if errs != nil {
return nil, errs
}
return &reporterMetrics{
ticks: ticks,
readingsEmitted: readingsEmitted,
collectErrors: collectErrors,
postErrors: postErrors,
checkpointErrors: checkpointErrors,
catchupDaysProcessed: catchupDaysProcessed,
collectDuration: collectDuration,
collectOperations: collectOperations,
shipDuration: shipDuration,
shipOperations: shipOperations,
}, nil
}

View File

@@ -150,72 +150,6 @@ func (provider *Provider) PutMetersV2(ctx context.Context, key string, data []by
return err
}
func (provider *Provider) PutMetersV3(ctx context.Context, key string, idempotencyKey string, data []byte) error {
headers := http.Header{}
if idempotencyKey != "" {
headers.Set("X-Idempotency-Key", idempotencyKey)
}
_, err := provider.doWithHeaders(
ctx,
provider.config.URL.JoinPath("/v2/meters"),
http.MethodPost,
key,
data,
headers,
)
return err
}
func (provider *Provider) GetMeterCheckpoints(ctx context.Context, key string) ([]zeustypes.MeterCheckpoint, error) {
response, err := provider.do(
ctx,
provider.config.URL.JoinPath("/v2/meters/checkpoints"),
http.MethodGet,
key,
nil,
)
if err != nil {
return nil, err
}
checkpointValues := gjson.GetBytes(response, "data.checkpoints")
if !checkpointValues.Exists() || checkpointValues.Type == gjson.Null {
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoints are required")
}
if !checkpointValues.IsArray() {
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoints must be an array")
}
checkpointResults := checkpointValues.Array()
checkpoints := make([]zeustypes.MeterCheckpoint, 0, len(checkpointResults))
for _, checkpointValue := range checkpointResults {
name := checkpointValue.Get("name").String()
if name == "" {
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoint name is required")
}
checkpointString := checkpointValue.Get("checkpoint").String()
if checkpointString == "" {
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoint is required for %q", name)
}
checkpoint, err := time.Parse("2006-01-02", checkpointString)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "parse meter checkpoint %q for %q", checkpointString, name)
}
checkpoints = append(checkpoints, zeustypes.MeterCheckpoint{
Name: name,
Checkpoint: checkpoint,
})
}
return checkpoints, nil
}
func (provider *Provider) PutProfile(ctx context.Context, key string, profile *zeustypes.PostableProfile) error {
body, err := json.Marshal(profile)
if err != nil {
@@ -251,21 +185,12 @@ func (provider *Provider) PutHost(ctx context.Context, key string, host *zeustyp
}
func (provider *Provider) do(ctx context.Context, url *url.URL, method string, key string, requestBody []byte) ([]byte, error) {
return provider.doWithHeaders(ctx, url, method, key, requestBody, nil)
}
func (provider *Provider) doWithHeaders(ctx context.Context, url *url.URL, method string, key string, requestBody []byte, extraHeaders http.Header) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewBuffer(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("X-Signoz-Cloud-Api-Key", key)
request.Header.Set("Content-Type", "application/json")
for k, vs := range extraHeaders {
for _, v := range vs {
request.Header.Add(k, v)
}
}
response, err := provider.httpClient.Do(request)
if err != nil {

View File

@@ -232,7 +232,6 @@
"ts-jest": "29.4.6",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",
"vite-plugin-image-optimizer": "2.0.3",

View File

@@ -22,8 +22,6 @@ import type {
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
ListAuthDomains200,
RenderErrorResponseDTO,
UpdateAuthDomainPathParameters,
@@ -279,109 +277,6 @@ export const useDeleteAuthDomain = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns an auth domain by ID
* @summary Get auth domain by ID
*/
export const getAuthDomain = (
{ id }: GetAuthDomainPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
method: 'GET',
signal,
});
};
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetAuthDomainQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthDomain>>> = ({
signal,
}) => getAuthDomain({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAuthDomainQueryResult = NonNullable<
Awaited<ReturnType<typeof getAuthDomain>>
>;
export type GetAuthDomainQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get auth domain by ID
*/
export function useGetAuthDomain<
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAuthDomainQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get auth domain by ID
*/
export const invalidateGetAuthDomain = async (
queryClient: QueryClient,
{ id }: GetAuthDomainPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAuthDomainQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates an auth domain
* @summary Update auth domain

View File

@@ -18,7 +18,6 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostableChannelDTO,
ConfigReceiverDTO,
CreateChannel201,
DeleteChannelByIDPathParameters,
@@ -123,14 +122,14 @@ export const invalidateListChannels = async (
* @summary Create notification channel
*/
export const createChannel = (
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
configReceiverDTO: BodyType<ConfigReceiverDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateChannel201>({
url: `/api/v1/channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostableChannelDTO,
data: configReceiverDTO,
signal,
});
};
@@ -142,13 +141,13 @@ export const getCreateChannelMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
> => {
const mutationKey = ['createChannel'];
@@ -162,7 +161,7 @@ export const getCreateChannelMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createChannel>>,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
{ data: BodyType<ConfigReceiverDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -175,8 +174,7 @@ export const getCreateChannelMutationOptions = <
export type CreateChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createChannel>>
>;
export type CreateChannelMutationBody =
BodyType<AlertmanagertypesPostableChannelDTO>;
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -189,13 +187,13 @@ export const useCreateChannel = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
> => {
const mutationOptions = getCreateChannelMutationOptions(options);

File diff suppressed because it is too large Load Diff

View File

@@ -47,16 +47,10 @@ function TanStackCustomTableRow<TData>({
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const enableAlternatingRowColors =
context?.enableAlternatingRowColors ?? false;
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
enableAlternatingRowColors &&
(item.row.index % 2 === 0
? tableStyles.tableRowEven
: tableStyles.tableRowOdd),
extraClass,
);
@@ -111,12 +105,6 @@ function areTableRowPropsEqual<TData>(
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
return false;
}
if (
prev.context?.enableAlternatingRowColors !==
next.context?.enableAlternatingRowColors
) {
return false;
}
if (prev.context !== next.context) {
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;

View File

@@ -2,10 +2,7 @@
position: sticky;
top: 0;
z-index: 2;
padding: var(--tanstack-cell-padding-top, 0.3rem)
var(--tanstack-cell-padding-right, 0.3rem)
var(--tanstack-cell-padding-bottom, 0.3rem)
var(--tanstack-cell-padding-left, 0.3rem);
padding: 0.3rem;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -22,17 +19,7 @@
}
border: none !important;
background-color: var(
--tanstack-table-header-cell-bg,
var(--l2-background)
) !important;
&:first-child {
background-color: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-header-cell-bg, var(--l2-background))
) !important;
}
background-color: var(--l2-background) !important;
}
.tanstackHeaderContent {
@@ -74,7 +61,7 @@
width: 12px;
height: 12px;
cursor: grab;
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
opacity: 1;
touch-action: none;
}
@@ -87,7 +74,7 @@
height: 20px;
cursor: pointer;
flex-shrink: 0;
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
margin-left: auto;
}
@@ -95,9 +82,8 @@
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
border: 1px solid
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
box-shadow: none;
}
@@ -151,7 +137,7 @@
}
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
background: var(--bg-robin-300);
}
.tanstackResizeHandleLine {
@@ -161,7 +147,7 @@
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
background: var(--l2-background);
opacity: 1;
pointer-events: none;
transition:
@@ -169,34 +155,13 @@
width 120ms ease;
}
.tanstackHeaderCell:first-child .tanstackResizeHandleLine {
background: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
);
}
.cursorColResize:hover .tanstackResizeHandleLine {
background: var(--tanstack-table-resize-handle-hover-bg, var(--l2-border));
}
.tanstackHeaderCell:first-child
.cursorColResize:hover
.tanstackResizeHandleLine {
background: color-mix(
in srgb,
var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
)
60%,
black
);
background: var(--l2-border);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
background: var(--bg-robin-500);
transition: none;
}
@@ -248,12 +213,7 @@
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l3-foreground);
&[data-sort-direction='asc'],
&[data-sort-direction='desc'] {
color: var(--primary);
}
color: var(--l2-foreground);
}
.isSortable {

View File

@@ -9,7 +9,7 @@ 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 { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
@@ -177,17 +177,12 @@ function TanStackHeaderRow<TData>({
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span
className={headerStyles.tanstackSortIndicator}
data-sort-direction={currentSortDirection || 'none'}
>
<span className={headerStyles.tanstackSortIndicator}>
{currentSortDirection === 'asc' ? (
<ArrowUp size={SORT_ICON_SIZE} />
<ChevronUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ArrowDown size={SORT_ICON_SIZE} />
) : (
<ArrowUpDown size={SORT_ICON_SIZE} />
)}
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
</span>
</button>
) : (

View File

@@ -1,22 +1,4 @@
.tanStackTable {
--tanstack-cell-padding: var(--tanstack-cell-padding-override, 0.3rem);
--tanstack-cell-padding-left: var(
--tanstack-cell-padding-left-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-right: var(
--tanstack-cell-padding-right-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-top: var(
--tanstack-cell-padding-top-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-bottom: var(
--tanstack-cell-padding-bottom-override,
var(--tanstack-cell-padding)
);
width: 100%;
border-collapse: separate;
border-spacing: 0;
@@ -44,7 +26,7 @@
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(--tanstack-table-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
max-width: 100%;
word-break: break-all;
}
@@ -60,35 +42,13 @@
}
.tableCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: var(--tanstack-table-row-height, auto);
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(--tanstack-table-cell-color, var(--l2-foreground));
background-color: var(--tanstack-table-cell-bg, transparent);
&:first-child {
padding: var(
--tanstack-cell-padding-top-first-column,
var(--tanstack-cell-padding-top)
)
var(
--tanstack-cell-padding-right-first-column,
var(--tanstack-cell-padding-right)
)
var(
--tanstack-cell-padding-bottom-first-column,
var(--tanstack-cell-padding-bottom)
)
var(
--tanstack-cell-padding-left-first-column,
var(--tanstack-cell-padding-left)
);
}
color: var(--l2-foreground);
}
.tableRow {
@@ -98,69 +58,19 @@
&:hover {
.tableCell {
background-color: var(
--tanstack-table-row-hover-bg,
var(--row-hover-bg)
) !important;
background-color: var(--row-hover-bg) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(
--tanstack-table-row-active-bg,
var(--row-active-bg)
) !important;
background-color: var(--row-active-bg) !important;
}
}
&.tableRowOdd {
.tableCell {
background-color: var(--tanstack-table-row-odd-bg, transparent);
}
}
&.tableRowEven {
.tableCell {
background-color: var(--tanstack-table-row-even-bg, transparent);
}
}
.tableCell:first-child {
background-color: var(
--tanstack-first-column-bg,
var(--tanstack-table-cell-bg, transparent)
);
color: var(
--tanstack-first-column-color,
var(--tanstack-table-cell-color, var(--l2-foreground))
);
}
&.tableRowOdd .tableCell:first-child {
background-color: var(
--tanstack-first-column-odd-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-odd-bg, transparent)
)
);
}
&.tableRowEven .tableCell:first-child {
background-color: var(
--tanstack-first-column-even-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-even-bg, transparent)
)
);
}
}
.tableHeaderCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
padding: 0.3rem;
height: 36px;
text-align: left;
font-size: 14px;
@@ -168,40 +78,20 @@
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
background: var(--tanstack-table-header-cell-bg, var(--l1-background));
border-bottom: 1px solid var(--tanstack-table-header-border-color, transparent);
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 {
background-color: var(--tanstack-table-header-cell-bg, var(--l1-background));
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
}
& > td {
padding: 0;
}
& thead th:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-3) - 1px)
);
}
& tbody td:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-20) + var(--spacing-4))
);
}
:global(thead) {
position: unset !important;
}
}
.tableCellExpansion {

View File

@@ -90,7 +90,6 @@ function TanStackTableInner<TData>(
skeletonRowCount = 10,
enableQueryParams,
pagination,
paginationClassname,
onEndReached,
getRowKey,
getItemKey,
@@ -113,17 +112,9 @@ function TanStackTableInner<TData>(
testId,
prefixPaginationContent,
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
if (disableVirtualScroll && onEndReached) {
throw new Error(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
}
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
@@ -230,15 +221,6 @@ function TanStackTableInner<TData>(
[getRowCanExpand],
);
const isExpandEnabled = Boolean(renderExpandedRow);
useEffect(() => {
const hasExpanded =
typeof expanded === 'boolean' ? expanded : Object.keys(expanded).length > 0;
if (!isExpandEnabled && hasExpanded) {
setExpanded({});
}
}, [isExpandEnabled, expanded, setExpanded]);
const table = useReactTable({
data: effectiveData,
columns: tanstackColumns,
@@ -247,7 +229,7 @@ function TanStackTableInner<TData>(
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: isExpandEnabled,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
@@ -351,7 +333,6 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
}),
[
getRowStyle,
@@ -369,7 +350,6 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
],
);
@@ -520,10 +500,7 @@ function TanStackTableInner<TData>(
);
return (
<div
className={cx(viewStyles.tanstackTableViewWrapper, className)}
data-has-group-by={(groupBy?.length || 0) > 0}
>
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
@@ -531,53 +508,23 @@ function TanStackTableInner<TData>(
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
{disableVirtualScroll ? (
<div
className={virtuosoClassName}
{...restTableScrollerProps}
data-testid={testId}
>
<table className={tableStyles.tanStackTable} style={virtuosoTableStyle}>
<VirtuosoTableColGroup columns={effectiveColumns} table={table} />
<thead>{tableHeader()}</thead>
<tbody>
{(isLoading && data.length === 0
? flatItems.slice(0, skeletonRowCount)
: flatItems
).map((item, index) => (
<TanStackCustomTableRow
key={
item.kind === 'expansion' ? `${item.row.id}-expansion` : item.row.id
}
item={item}
context={virtuosoContext}
data-index={index}
data-item-index={index}
data-known-size={0}
/>
))}
</tbody>
</table>
</div>
) : (
<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}
/>
)}
<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}
@@ -587,7 +534,7 @@ function TanStackTableInner<TData>(
</div>
)}
{showPagination && pagination && (
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
<div className={viewStyles.paginationContainer}>
{prefixPaginationContent}
<Pagination
current={page}

View File

@@ -49,8 +49,7 @@
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-slate-300))
transparent;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
@@ -66,12 +65,12 @@
}
&::-webkit-scrollbar-thumb {
background: var(--tanstack-table-scrollbar-color, var(--bg-slate-300));
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--tanstack-table-scrollbar-hover-color, var(--bg-slate-200));
background: var(--bg-slate-200);
}
&.cellTypographySmall {
@@ -136,25 +135,18 @@
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
background: var(--tanstack-table-loading-overlay-bg, var(--l1-background));
box-shadow: var(
--tanstack-table-loading-overlay-shadow,
0 2px 8px rgba(0, 0, 0, 0.15)
);
background: var(--l1-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300))
transparent;
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300));
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(
--tanstack-table-scrollbar-hover-color,
var(--bg-vanilla-100)
);
background: var(--bg-vanilla-100);
}
}

View File

@@ -389,101 +389,6 @@ describe('TanStackTableView Integration', () => {
// by checking the table renders without errors
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders without errors when expanded state exists but expansion is disabled', async () => {
// This tests that the table handles the case where URL has expanded state
// but renderExpandedRow is undefined (expansion disabled).
// The table's useEffect should reset expanded state automatically.
renderTanStackTable({
props: {
enableQueryParams: true,
// renderExpandedRow is undefined - expansion disabled
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Table should render without any expanded rows
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
});
it('renders expanded rows with unique keys in non-virtualized mode', async () => {
// This tests that row and expansion items have unique keys to avoid
// React's "duplicate key" warning when disableVirtualScroll is true
renderTanStackTable({
props: {
disableVirtualScroll: true,
enableQueryParams: true,
renderExpandedRow: (row) => (
<div data-testid={`expanded-${row.id}`}>Expanded: {row.name}</div>
),
getRowCanExpand: () => true,
getRowKey: (row) => row.id,
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Both the row and its expansion content should be rendered
expect(screen.getByTestId('expanded-1')).toBeInTheDocument();
expect(screen.getByText('Expanded: Item 1')).toBeInTheDocument();
// Verify all 3 data rows plus 1 expansion row = 4 tr elements in tbody
const tbody = screen.getByRole('table').querySelector('tbody');
expect(tbody?.querySelectorAll('tr')).toHaveLength(4);
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
onEndReached: jest.fn(),
},
});
}).toThrow(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
});
it('renders all rows without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
// Verify table structure exists
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders column headers without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
});
});
describe('infinite scroll', () => {

View File

@@ -138,10 +138,7 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
wrapper,
});
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(3);
});
@@ -252,294 +249,3 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
expect(result.current.orderBy).toBeNull();
});
});
describe('useTableParams (selective URL mode — partial config object)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('syncs only page to URL when only page is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ page: 'myPage' }), {
wrapper,
});
// Update page - should sync to URL
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
expect(result.current.page).toBe(5);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myPage'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('5');
// Update limit - should stay local (not in URL)
act(() => {
result.current.setLimit(100);
jest.runAllTimers();
});
expect(result.current.limit).toBe(100);
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('limit') !== null,
);
expect(limitInUrl).toBe(false);
// Update orderBy - should stay local (not in URL)
act(() => {
result.current.setOrderBy({ columnName: 'test', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'test',
order: 'asc',
});
const orderByInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('order_by') !== null,
);
expect(orderByInUrl).toBe(false);
});
it('syncs only orderBy to URL when only orderBy is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ orderBy: 'mySort' }), {
wrapper,
});
// Update orderBy - should sync to URL
act(() => {
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('mySort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
expect(JSON.parse(lastOrderBy!)).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
// Update page - should stay local
act(() => {
result.current.setPage(3);
jest.runAllTimers();
});
expect(result.current.page).toBe(3);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only limit to URL when only limit is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ limit: 'myLimit' }), {
wrapper,
});
// Update limit - should sync to URL
act(() => {
result.current.setLimit(25);
jest.runAllTimers();
});
expect(result.current.limit).toBe(25);
const lastLimit = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myLimit'))
.filter(Boolean)
.pop();
expect(lastLimit).toBe('25');
// Update page - should stay local
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only expanded to URL when only expanded is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ expanded: 'myExpanded' }),
{ wrapper },
);
// Update expanded - should sync to URL
act(() => {
result.current.setExpanded({ 'row-1': true, 'row-2': true });
jest.runAllTimers();
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
const lastExpanded = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myExpanded'))
.filter(Boolean)
.pop();
expect(lastExpanded).toBeDefined();
expect(JSON.parse(lastExpanded!)).toEqual(
expect.arrayContaining(['row-1', 'row-2']),
);
// Update page - should stay local
act(() => {
result.current.setPage(4);
jest.runAllTimers();
});
expect(result.current.page).toBe(4);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs page and orderBy to URL but keeps limit and expanded local', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ page: 'p', orderBy: 'sort' }),
{ wrapper },
);
// Update limit and expanded first (should stay local)
act(() => {
result.current.setLimit(75);
result.current.setExpanded({ 'row-5': true });
jest.runAllTimers();
});
expect(result.current.limit).toBe(75);
expect(result.current.expanded).toStrictEqual({ 'row-5': true });
// Update page (should sync to URL)
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('p'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
// Update orderBy (should sync to URL, and resets page to default)
act(() => {
result.current.setOrderBy({ columnName: 'name', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'name',
order: 'asc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('sort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
// limit should NOT be in URL
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('limit') !== null ||
call[0].searchParams.get('myLimit') !== null,
);
expect(limitInUrl).toBe(false);
// expanded should NOT be in URL
const expandedInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('expanded') !== null ||
call[0].searchParams.get('myExpanded') !== null,
);
expect(expandedInUrl).toBe(false);
});
it('reads initial values from URL for configured params only', () => {
const wrapper = createNuqsWrapper({
customPage: '7',
limit: '999', // This should be ignored since limit is not configured
});
const { result } = renderHook(
// Pass page default matching URL to prevent reset on mount
() => useTableParams({ page: 'customPage' }, { page: 7 }),
{ wrapper },
);
// Page should come from URL
expect(result.current.page).toBe(7);
// Limit should be default (not from URL since it's not configured)
expect(result.current.limit).toBe(50);
});
it('supports updater function for expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams({ expanded: 'exp' }), {
wrapper,
});
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-1': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-1': true });
// Use updater function to add another row
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-2': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
});
it('supports updater function for local expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-a': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-a': true });
// Use updater function
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-b': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-a': true,
'row-b': true,
});
});
});

View File

@@ -171,27 +171,6 @@ export * from './useTableParams';
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
* />
* ```
*
* @example Disable virtual scroll — useful for nested tables inside expanded rows.
* Virtual scroll requires a fixed height container, which is problematic for nested tables
* that need dynamic height. Use `disableVirtualScroll` when rendering tables inside
* `renderExpandedRow` to allow the nested table to grow based on content.
* Note: Cannot be combined with `onEndReached` (infinite scroll requires virtualization).
* ```tsx
* // Parent table with expandable rows
* <TanStackTable
* data={parentData}
* columns={parentColumns}
* renderExpandedRow={(row) => (
* // Nested table without virtualization — height adapts to content
* <TanStackTable
* data={row.children}
* columns={childColumns}
* disableVirtualScroll
* />
* )}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -107,8 +107,6 @@ export type TableRowContext<TData> = {
columnOrderKey: string;
/** Column visibility key for memo invalidation on visibility change */
columnVisibilityKey: string;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
};
export type PaginationProps = {
@@ -118,10 +116,10 @@ export type PaginationProps = {
};
export type TanstackTableQueryParamsConfig = {
page?: string;
limit?: string;
orderBy?: string;
expanded?: string;
page: string;
limit: string;
orderBy: string;
expanded: string;
};
export type TanStackTableProps<TData> = {
@@ -139,7 +137,6 @@ export type TanStackTableProps<TData> = {
skeletonRowCount?: number;
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
paginationClassname?: string;
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. */
@@ -179,10 +176,6 @@ export type TanStackTableProps<TData> = {
prefixPaginationContent?: ReactNode;
/** Content rendered after the pagination controls */
suffixPaginationContent?: ReactNode;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
disableVirtualScroll?: boolean;
};
export type TanStackTableHandle = TableVirtuosoHandle & {

View File

@@ -8,12 +8,6 @@ import { SortState, TanstackTableQueryParamsConfig } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
const URL_KEYS_DEFAULT = {
page: 'page',
limit: 'limit',
orderBy: 'order_by',
expanded: 'expanded',
} as const;
type Defaults = {
page?: number;
@@ -55,49 +49,30 @@ export function useTableParams(
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
defaults?: Defaults,
): TableParamsResult {
// Determine which params should sync to URL vs use local state
const isObjectConfig = typeof enableQueryParams === 'object';
const useUrlForPage =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.page !== undefined);
const useUrlForLimit =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.limit !== undefined);
const useUrlForOrderBy =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.orderBy !== undefined);
const useUrlForExpanded =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.expanded !== undefined);
const pageQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
: isObjectConfig
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
: URL_KEYS_DEFAULT.page;
? `${enableQueryParams}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
: isObjectConfig
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
: URL_KEYS_DEFAULT.limit;
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
: isObjectConfig
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
: URL_KEYS_DEFAULT.orderBy;
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
: isObjectConfig
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
? `${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;
@@ -174,29 +149,45 @@ export function useTableParams(
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const isEnabledQueryParams =
typeof enableQueryParams === 'string' ||
typeof enableQueryParams === 'object';
useEffect(() => {
if (useUrlForPage) {
if (isEnabledQueryParams) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
useUrlForPage,
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: useUrlForPage ? urlPage : localPage,
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
};
}

View File

@@ -35,10 +35,7 @@ export const getColumnWidthStyle = <TData>(
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
};
return { width: '100%' };
}
const { width } = column;
@@ -62,19 +59,10 @@ export const getColumnWidthStyle = <TData>(
};
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');
};
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
// Skip accessor for skeleton rows to avoid errors with missing properties
if (isSkeletonRow(row)) {
return undefined;
}
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}

View File

@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -0,0 +1,190 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,143 +0,0 @@
.overview-content {
display: flex;
flex-direction: column;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.name-icon-input {
display: flex;
.dashboard-image-input {
.ant-select-selector {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.dashboard-name-input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.dashboard-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-text-area {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.overview-settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
.unsaved {
display: flex;
align-items: center;
gap: 8px;
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsaved-changes {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
}
.footer-action-btns {
display: flex;
gap: 8px;
.discard-btn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.save-btn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
}
.dashboard-image-input {
&.ant-select-dropdown {
padding: 0px !important;
}
.ant-select-item {
padding: 0px;
align-items: center;
justify-content: center;
.ant-select-item-option-content {
display: flex;
align-items: center;
justify-content: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}

View File

@@ -1,17 +1,22 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import './GeneralSettings.styles.scss';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
@@ -19,6 +24,13 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
@@ -100,8 +112,8 @@ function GeneralDashboardSettings(): JSX.Element {
};
return (
<div className="overview-content">
<Col className="overview-settings">
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
@@ -112,27 +124,29 @@ function GeneralDashboardSettings(): JSX.Element {
}}
>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Dashboard Name
</Typography>
<section className="name-icon-input">
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName="dashboard-image-input"
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img src={icon} alt="dashboard-icon" className="list-item-image" />
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
@@ -140,41 +154,88 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Description
</Typography>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className="description-text-area"
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Tags
</Typography>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className="overview-settings-footer">
<div className="unsaved">
<div className="unsaved-dot" />
<Typography.Text className="unsaved-changes">
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className="footer-action-btns">
<div className={styles.footerActionBtns}>
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className="discard-btn"
className={styles.discardBtn}
>
Discard
</Button>
@@ -188,7 +249,7 @@ function GeneralDashboardSettings(): JSX.Element {
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className="save-btn"
className={styles.saveBtn}
>
{t('save')}
</Button>

View File

@@ -29,6 +29,7 @@ export default function ChartWrapper({
onClick,
syncMode,
syncKey,
syncFilterMode,
onDestroy = noop,
children,
layoutChildren,
@@ -70,8 +71,9 @@ export default function ChartWrapper({
() => ({
yAxisUnit,
groupBy,
filterMode: syncFilterMode,
}),
[yAxisUnit, groupBy],
[yAxisUnit, groupBy, syncFilterMode],
);
return (

View File

@@ -4,6 +4,7 @@ import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -30,6 +31,7 @@ interface UPlotBasedChartProps {
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncFilterMode?: SyncTooltipFilterMode;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;

View File

@@ -1,9 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -34,6 +37,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -75,6 +82,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -122,6 +134,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -138,6 +151,8 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -3,10 +3,13 @@ import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSe
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -33,6 +36,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -81,6 +88,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -113,6 +125,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -125,6 +138,8 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
groupBy={groupBy}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
layoutChildren={layoutChildren}
>
<ContextMenu

View File

@@ -11,8 +11,8 @@ import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -32,9 +32,9 @@ import {
hostWidgetInfo,
} from './constants';
import {
getHostItemKey,
getHostRowKey,
hostColumns,
hostColumnsConfig,
hostRenderRowData,
} from './table.config';
import { getHostsQuickFiltersConfig } from './utils';
@@ -42,8 +42,8 @@ import styles from './InfraMonitoringHosts.module.scss';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
@@ -170,10 +170,10 @@ function Hosts(): JSX.Element {
<K8sBaseList
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.HOSTS}
tableColumnsDefinitions={hostColumns}
tableColumns={hostColumnsConfig}
fetchListData={fetchListData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
renderRowData={hostRenderRowData}
eventCategory={InfraMonitoringEvents.HostEntity}
/>
</div>

View File

@@ -0,0 +1,190 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useQueryBuilderOperations from 'hooks/queryBuilder/useQueryBuilderOperations';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import Hosts from '../Hosts';
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
minTime: 1713734400000,
maxTime: 1713738000000,
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
})),
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
minTime: 1713734400000000000,
maxTime: 1713738000000000000,
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
__esModule: true,
default: ({ onSelect, selectedTime, selectedValue }: any): JSX.Element => (
<div data-testid="custom-time-picker">
<button onClick={(): void => onSelect('custom')}>
{selectedTime} - {selectedValue}
</button>
</div>
),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.mock('react-router-dom', () => {
const ROUTES = jest.requireActual('constants/routes').default;
return {
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({
pathname: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
}),
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
timezone: {
offset: 0,
},
browserTimezone: {
offset: 0,
},
} as any);
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
statusCode: 200,
error: null,
message: 'Success',
payload: {
status: 'success',
data: {
type: 'list',
records: [
{
hostName: 'test-host',
active: true,
os: 'linux',
cpu: 0.75,
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
memory: 0.65,
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
wait: 0.03,
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15: 0.5,
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
},
],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
},
},
params: {} as any,
});
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
featureFlags: [],
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
} as any);
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
currentQuery: initialQueriesMap.metrics,
setSupersetQuery: jest.fn(),
setLastUsedQuery: jest.fn(),
handleSetConfig: jest.fn(),
resetQuery: jest.fn(),
updateAllQueriesOperators: jest.fn(),
} as any);
jest.spyOn(useQueryBuilderOperations, 'useQueryOperations').mockReturnValue({
handleChangeQueryData: jest.fn(),
} as any);
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('Hosts', () => {
beforeEach(() => {
queryClient.clear();
});
it('renders hosts list table', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<Hosts />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.ant-table')).toBeInTheDocument();
});
});
it('renders filters', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<Hosts />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,143 @@
import { render, screen } from '@testing-library/react';
import { HostData, TimeSeries } from 'api/infraMonitoring/getHostLists';
import { hostRenderRowData } from '../table.config';
import { getHostsQuickFiltersConfig, HostnameCell } from '../utils';
const emptyTimeSeries: TimeSeries = {
labels: {},
labelsArray: [],
values: [],
};
describe('InfraMonitoringHosts utils', () => {
describe('hostRenderRowData', () => {
it('should format host data correctly', () => {
const host: HostData = {
hostName: 'test-host',
active: true,
cpu: 0.95,
memory: 0.85,
wait: 0.05,
load15: 2.5,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
expect(result.wait).toBe('5%');
expect(result.load15).toBe(2.5);
expect(result.itemKey).toBe('test-host');
expect(result.hostName).toBe('test-host');
const activeTag = render(result.active as JSX.Element);
expect(activeTag.container.textContent).toBe('ACTIVE');
expect(activeTag.getByText('ACTIVE')).toBeTruthy();
const cpuProgress = render(result.cpu as JSX.Element);
expect(cpuProgress.container.querySelector('.ant-progress')).toBeTruthy();
const memoryProgress = render(result.memory as JSX.Element);
expect(memoryProgress.container.querySelector('.ant-progress')).toBeTruthy();
});
it('should handle inactive hosts', () => {
const host: HostData = {
hostName: 'test-host',
active: false,
cpu: 0.3,
memory: 0.4,
wait: 0.02,
load15: 1.2,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
const inactiveTag = render(result.active as JSX.Element);
expect(inactiveTag.container.textContent).toBe('INACTIVE');
expect(inactiveTag.getByText('INACTIVE')).toBeTruthy();
});
it('should use empty itemKey when host has no hostname', () => {
const host: HostData = {
hostName: '',
active: true,
cpu: 0.5,
memory: 0.4,
wait: 0.01,
load15: 1.0,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
expect(result.itemKey).toBe('');
});
});
describe('HostnameCell', () => {
it('should render hostname when present (case A: no icon)', () => {
const { container } = render(<HostnameCell hostName="gke-prod-1" />);
expect(container.querySelector('.hostname-column-value')).toBeTruthy();
expect(container.textContent).toBe('gke-prod-1');
expect(container.querySelector('.hostname-cell-missing')).toBeFalsy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeFalsy();
});
it('should render placeholder and icon when hostName is empty (case B)', () => {
const { container } = render(<HostnameCell hostName="" />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
const iconWrapper = container.querySelector('.hostname-cell-warning-icon');
expect(iconWrapper).toBeTruthy();
expect(iconWrapper?.getAttribute('aria-label')).toBe(
'Missing host.name metadata',
);
expect(iconWrapper?.getAttribute('tabindex')).toBe('0');
});
it('should render placeholder and icon when hostName is whitespace only (case C)', () => {
const { container } = render(<HostnameCell hostName=" " />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
it('should render placeholder and icon when hostName is undefined (case D)', () => {
const { container } = render(<HostnameCell hostName={undefined} />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
});
describe('getHostsQuickFiltersConfig', () => {
it('should return correct config when dotMetricsEnabled is true', () => {
const result = getHostsQuickFiltersConfig(true);
expect(result[0].attributeKey.key).toBe('host.name');
expect(result[1].attributeKey.key).toBe('os.type');
expect(result[0].aggregateAttribute).toBe('system.cpu.load_average.15m');
});
it('should return correct config when dotMetricsEnabled is false', () => {
const result = getHostsQuickFiltersConfig(false);
expect(result[0].attributeKey.key).toBe('host_name');
expect(result[1].attributeKey.key).toBe('os_type');
expect(result[0].aggregateAttribute).toBe('system_cpu_load_average_15m');
});
});
});

View File

@@ -1,22 +1,160 @@
import React from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tag, Tooltip } from 'antd';
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import { HostData } from 'api/infraMonitoring/getHostLists';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8s/Base/utils';
import { K8sRenderedRowData } from 'container/InfraMonitoringK8s/Base/types';
import { IEntityColumn } from 'container/InfraMonitoringK8s/Base/useInfraMonitoringTableColumnsStore';
import {
EntityProgressBar,
ExpandButtonWrapper,
ValidateColumnValueWrapper,
} from 'container/InfraMonitoringK8s/components';
getGroupByEl,
getGroupedByMeta,
getRowKey,
} from 'container/InfraMonitoringK8s/Base/utils';
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8s/commonUtils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8s/hooks';
import EntityGroupHeader from 'container/InfraMonitoringK8s/Base/EntityGroupHeader';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getMemoryProgressColor, getProgressColor } from './constants';
import { HostnameCell } from './utils';
import styles from './table.module.scss';
import { Container } from 'lucide-react';
export const hostColumns: IEntityColumn[] = [
{
label: 'Host group',
value: 'hostGroup',
id: 'hostGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Hostname',
value: 'hostName',
id: 'hostName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Status',
value: 'active',
id: 'active',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'IOWait',
value: 'wait',
id: 'wait',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Load Avg',
value: 'load15',
id: 'load15',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const hostColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> HOST GROUP
</div>
),
dataIndex: 'hostGroup',
key: 'hostGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div className={styles.hostnameColumnHeader}>Hostname</div>,
dataIndex: 'hostName',
key: 'hostName',
width: 250,
render: (_value, record): React.ReactNode => (
<HostnameCell
hostName={typeof record.hostName === 'string' ? record.hostName : ''}
/>
),
},
{
title: (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'active',
key: 'active',
width: 100,
},
{
title: <div className={styles.columnHeaderRight}>CPU Usage</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 100,
sorter: true,
align: 'right',
},
{
title: (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'memory',
key: 'memory',
width: 100,
sorter: true,
align: 'right',
},
{
title: <div className={styles.columnHeaderRight}>IOWait</div>,
dataIndex: 'wait',
key: 'wait',
width: 100,
sorter: true,
align: 'right',
},
{
title: <div className={styles.columnHeaderRight}>Load Avg</div>,
dataIndex: 'load15',
key: 'load15',
width: 100,
sorter: true,
align: 'right',
},
];
function hostRowSource(host: HostData): { meta: Record<string, string> } {
return {
@@ -30,154 +168,67 @@ function hostRowSource(host: HostData): { meta: Record<string, string> } {
};
}
export function getHostRowKey(host: HostData): string {
return host.hostName || 'unknown';
}
export const hostRenderRowData = (
host: HostData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => {
const synthetic = hostRowSource(host);
const rowKey = getRowKey(synthetic, () => host.hostName || 'unknown', groupBy);
const groupedByMeta = getGroupedByMeta(synthetic, groupBy);
const cpuPercent = Number((host.cpu * 100).toFixed(1));
const memoryPercent = Number((host.memory * 100).toFixed(1));
export function getHostItemKey(host: HostData): string {
return host.hostName ?? '';
}
function HostGroupCell({ row }: { row: HostData }): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
const synthetic = hostRowSource(row);
return getGroupByEl(synthetic, groupBy) as JSX.Element;
}
export const hostColumnsConfig: TableColumnDef<HostData>[] = [
{
id: 'hostGroup',
header: (): React.ReactNode => <EntityGroupHeader title="HOST GROUP" />,
accessorFn: (row): string => row.hostName ?? '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, isExpanded, toggleExpanded }): React.ReactNode => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<HostGroupCell row={row} />
</ExpandButtonWrapper>
return {
key: rowKey,
itemKey: host.hostName ?? '',
groupedByMeta,
meta: synthetic.meta,
hostGroup: getGroupByEl(synthetic, groupBy),
...synthetic.meta,
hostName: host.hostName ?? '',
active: (
<Tag
bordered
className={`${styles.statusTag} ${
host.active ? styles.statusTagActive : styles.statusTagInactive
}`}
>
{host.active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
),
},
{
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader title="Hostname" icon={<Container size={14} />} />
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<HostnameCell hostName={value as string} />
),
},
{
id: 'active',
header: (): React.ReactNode => (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
accessorFn: (row): boolean => row.active,
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const active = value as boolean;
return (
<Tag
bordered
className={`${styles.statusTag} ${
active ? styles.statusTagActive : styles.statusTagInactive
}`}
cpu: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.cpu}
entity={InfraMonitoringEntity.HOSTS}
>
{active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
);
},
},
{
id: 'cpu',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>CPU Usage</div>
),
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'memory',
header: (): React.ReactNode => (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<InfoCircleOutlined />
</Tooltip>
<Progress
percent={cpuPercent}
strokeLinecap="butt"
size="small"
strokeColor={getProgressColor(cpuPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
</div>
),
accessorFn: (row): number => row.memory,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.HOSTS}
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>IOWait</div>
memory: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.memory}
entity={InfraMonitoringEntity.HOSTS}
>
<Progress
percent={memoryPercent}
strokeLinecap="butt"
size="small"
strokeColor={getMemoryProgressColor(memoryPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
</div>
),
accessorFn: (row): number => row.wait,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const wait = value as number;
return (
<TanStackTable.Text>{`${Number((wait * 100).toFixed(1))}%`}</TanStackTable.Text>
);
},
},
{
id: 'load15',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>Load Avg</div>
),
accessorFn: (row): number => row.load15,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as number}</TanStackTable.Text>
),
},
];
wait: `${Number((host.wait * 100).toFixed(1))}%`,
load15: host.load15,
};
};

View File

@@ -1,21 +0,0 @@
import { Group } from 'lucide-react';
import styles from './EntityGroupHeader.module.scss';
interface EntityGroupHeaderProps {
title: string;
icon?: React.ReactNode;
}
function EntityGroupHeader({
title,
icon,
}: EntityGroupHeaderProps): JSX.Element {
return (
<div className={styles.entityGroupHeader}>
{icon || <Group size={14} data-hide-expanded="true" />} {title}
</div>
);
}
export default EntityGroupHeader;

View File

@@ -37,7 +37,10 @@ import {
X,
} from 'lucide-react';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -187,19 +190,16 @@ function K8sBaseDetails<T>({
);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const { startMs, endMs } = useMemo(() => {
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
return {
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
};
}, [getMinMaxTime, selectedTime]);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
[queryKeyPrefix, selectedItem, selectedTime],
);
const {

View File

@@ -1,40 +1,175 @@
.emptyStateContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 400px;
.clickableRow {
cursor: pointer;
}
.k8SListTable {
padding-left: var(--spacing-2);
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--k8s-base-list-pagination-offset: 64px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
display: flex;
flex-direction: column;
--tanstack-expansion-first-col-padding-left: 30px;
:global(.ant-spin-nested-loading) {
flex: 1;
display: flex;
flex-direction: column;
}
&[data-has-group-by='false'] {
--tanstack-cell-padding-left-first-column: 28px;
:global(.ant-spin-container) {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-bottom: var(--k8s-base-list-pagination-offset);
}
:global(.ant-table-container) {
flex: 1;
display: flex;
flex-direction: column;
}
:global(.ant-table-header) {
flex-shrink: 0;
}
:global(.ant-table-body) {
flex: 1;
min-height: 0;
overflow: auto !important;
}
:global(.ant-table) {
flex: 1;
background: var(--l1-background) !important;
:global(.ant-table-thead > tr > th) {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: var(--l1-background) !important;
&::before {
background: transparent !important;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--l1-background);
border-bottom: none;
&:before,
&:after {
display: none;
}
}
:global(.progress-container) {
:global(.ant-progress-bg) {
height: 8px !important;
border-radius: 4px;
}
}
:global(.ant-table-tbody > tr:hover > td) {
background: var(--l1-background-hover);
}
:global(.ant-table-tbody > tr:not(.ant-table-expanded-row):hover > td) {
background: var(--l1-background-hover) !important;
}
:global(.ant-table-tbody > tr.ant-table-expanded-row:hover > td) {
background: var(--l1-background);
}
:global(.ant-table-tbody > tr > td) {
border-bottom: none;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
:global(.ant-table-cell) {
min-width: 180px !important;
max-width: 180px !important;
}
:global(.ant-table-cell:first-of-type):not(
:global(.ant-table-row-expand-icon-cell)
) {
min-width: 250px !important;
max-width: 250px !important;
}
:global(.ant-table-row-expand-icon-cell) {
min-width: 40px !important;
max-width: 40px !important;
}
:global(.ant-table-content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: color-mix(var(--accent-primary), transparent 40%);
}
}
}
}
.paginationContainer {
padding-bottom: var(--spacing-8);
padding-right: var(--spacing-8);
.paginationDock {
position: fixed;
bottom: 0;
right: 0;
width: calc(100% - 340px) !important;
margin: 0 !important;
padding: 16px;
background-color: var(--l1-background);
z-index: 1;
ul {
margin: 0;
:global(.ant-pagination-item) {
border-radius: 4px;
}
:global(.ant-pagination-item-active) {
background: var(--l2-background);
border-color: var(--l2-border);
a {
color: var(--l1-foreground) !important;
}
}
}

View File

@@ -1,36 +1,48 @@
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Typography } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { InfraMonitoringEntity } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPageSizeListing,
} from '../hooks';
import { usePageSize } from '../utils';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sHeader from './K8sHeader';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import {
IEntityColumn,
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
@@ -41,13 +53,11 @@ export type K8sBaseListEmptyStateContext = {
rawData?: unknown;
};
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> };
export type K8sBaseListProps<T extends K8sEntityData> = {
export type K8sBaseListProps<T = unknown> = {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
tableColumnsDefinitions: IEntityColumn[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -57,63 +67,69 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
};
export function K8sBaseList<T extends K8sEntityData>({
export function K8sBaseList<T>({
controlListPrefix,
entity,
tableColumnsDefinitions,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
renderRowData,
eventCategory,
renderEmptyState,
}: K8sBaseListProps<T>): JSX.Element {
const [queryFilters] = useInfraMonitoringFiltersK8s();
const [currentPage] = useInfraMonitoringPageListing();
const [currentPageSize] = useInfraMonitoringPageSizeListing();
const [queryFilters] = useInfraMonitoringFilters();
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [initialOrderBy] = useState(orderBy);
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
useEffect(() => {
setExpandedRowKeys([]);
}, [groupBy, currentPage]);
const { pageSize, setPageSize } = usePageSize(entity);
const initializeTableColumns = useInfraMonitoringTableColumnsStore(
(state) => state.initializePageColumns,
);
useEffect(() => {
initializeTableColumns(entity, tableColumnsDefinitions);
}, [initializeTableColumns, entity, tableColumnsDefinitions]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(currentPageSize),
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
currentPageSize,
pageSize,
currentPage,
queryFilters,
orderBy,
@@ -127,8 +143,8 @@ export function K8sBaseList<T extends K8sEntityData>({
return fetchListData(
{
limit: currentPageSize,
offset: (currentPage - 1) * currentPageSize,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters || { items: [], op: 'AND' },
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
@@ -141,14 +157,62 @@ export function K8sBaseList<T extends K8sEntityData>({
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const pageData = data?.data ?? [];
const pageData = data?.data;
const totalCount = data?.total || 0;
const hasFilters = (queryFilters?.items?.length ?? 0) > 0;
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],
);
const formattedItemsData = useMemo(() => {
if (!pageData) {
return undefined;
}
const rows = pageData.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
// eslint-disable-next-line sonarjs/no-identical-functions
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [pageData, renderRowData, groupBy]);
const handleTableChange: TableProps<K8sRenderedRowData>['onChange'] =
useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter:
| SorterResult<K8sRenderedRowData>
| SorterResult<K8sRenderedRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[eventCategory, setCurrentPage, setOrderBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
@@ -159,137 +223,214 @@ export function K8sBaseList<T extends K8sEntityData>({
});
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length === 0) {
setSelectedItem(itemKey);
}
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
const openItemInNewTab = (record: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', record.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sRenderedRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openItemInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedItem(record.itemKey);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const [columnsDefinitions, columnsHidden] =
useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsOnList = useMemo(
() =>
columnsDefinitions
.filter(
(col) =>
(groupBy?.length > 0 && col.behavior === 'hidden-on-expand') ||
(!groupBy?.length && col.behavior === 'hidden-on-collapse'),
)
.map((col) => col.id),
[columnsDefinitions, groupBy?.length],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length > 0) {
return;
const mapDefaultSort = useCallback(
(
tableColumn: ColumnType<K8sRenderedRowData>,
): ColumnType<K8sRenderedRowData> => {
if (tableColumn.key === initialOrderBy?.columnName) {
return {
...tableColumn,
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
};
}
// Build URL with selectedItem param
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
openInNewTab(url.pathname + url.search);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
return tableColumn;
},
[eventCategory, groupBy.length],
[initialOrderBy?.columnName, initialOrderBy?.order],
);
const columns = useMemo(
() =>
tableColumns
.filter(
(c) =>
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
!columnsHidden.includes(c.key?.toString() || ''),
)
.map(mapDefaultSort),
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
);
const isGroupedByAttribute = groupBy.length > 0;
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
[tableColumns, hiddenColumnIds],
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
<K8sExpandedRow<T>
record={record}
entity={entity}
tableColumns={tableColumns}
fetchListData={fetchListData}
renderRowData={renderRowData}
/>
);
const renderExpandedRow = useCallback(
(
_record: T,
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
tableColumns={expandedRowColumns}
fetchListData={fetchListData}
getRowKey={getRowKey}
getItemKey={getItemKey}
/>
),
[entity, fetchListData, getRowKey, getItemKey, expandedRowColumns],
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sRenderedRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sRenderedRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
const getRowCanExpand = useCallback(
(): boolean => isGroupedByAttribute,
[isGroupedByAttribute],
);
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
isError,
const emptyStateContext: K8sBaseListEmptyStateContext = {
isError: isError || !!data?.error,
error: data?.error,
totalCount,
hasFilters,
isLoading: showTableLoadingState,
rawData: data?.rawData,
}) || (
};
const emptyTableMessage: React.ReactNode = renderEmptyState?.(
emptyStateContext,
) || (
<K8sEmptyState
isError={isError}
error={data?.error}
isLoading={showTableLoadingState}
rawData={data?.rawData}
isError={emptyStateContext.isError}
error={emptyStateContext.error}
isLoading={emptyStateContext.isLoading}
rawData={emptyStateContext.rawData}
/>
);
const showEmptyState = !showTableLoadingState && pageData.length === 0;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isLoading={showTableLoadingState}
getRowKey={getRowKey}
getItemKey={getItemKey}
groupBy={groupBy}
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable, expandedRowColumns)}
enableQueryParams={{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
orderBy: INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
expanded: INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED,
}}
pagination={{
total: totalCount,
defaultLimit: 10,
defaultPage: 1,
}}
paginationClassname={styles.paginationContainer}
/>
)}
<Table
className={styles.k8SListTable}
dataSource={showTableLoadingState ? [] : formattedItemsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
className: styles.paginationDock,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : emptyTableMessage,
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: styles.clickableRow,
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
</>
);
}

View File

@@ -21,7 +21,6 @@
.title {
font-weight: 500;
margin: 0;
font-size: var(--periscope-font-size-medium);
}
.message {

View File

@@ -7,7 +7,7 @@ import { AlertTriangle, LifeBuoy } from 'lucide-react';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
import { K8sBaseListEmptyStateContext } from './K8sBaseList';
import styles from './K8sEmptyState.module.scss';

View File

@@ -1,35 +1,28 @@
.expandedTableContainer {
overflow-x: auto;
.expandedClickableRow {
cursor: pointer;
}
.expandedTable {
--tanstack-table-header-cell-bg: var(--l1-background);
--tanstack-table-header-cell-color: var(--l1-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l1-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: var(--l1-background);
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
.expandedTableContainer {
border: 1px solid var(--l1-border);
overflow-x: auto;
padding-left: 48px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {
display: none;
:global(.ant-table-tbody > tr:hover > td) {
background: none !important;
}
}
.expandedTableFooter {
display: flex;
justify-content: flex-start;
gap: var(--spacing-4);
background-color: var(--l1-background);
gap: 8px;
padding: 8px;
padding-left: 42px;
margin-top: 8px;
}
.viewAllButton {
display: flex;
align-items: center;
margin: var(--spacing-4);
gap: var(--spacing-2);
}

View File

@@ -1,40 +1,43 @@
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/ui';
import { Typography } from 'antd';
import TanStackTable, {
SortState,
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
Typography,
} from 'antd';
import { CornerDownRight } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
} from '../hooks';
import { K8sBaseFilters } from './types';
import LoadingContainer from '../LoadingContainer';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import { useInfraMonitoringTableColumnsForPage } from './useInfraMonitoringTableColumnsStore';
import styles from './K8sExpandedRow.module.scss';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
groupMeta?: Record<string, string>;
record: K8sRenderedRowData;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -44,46 +47,47 @@ export type K8sExpandedRowProps<T> = {
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
};
export const MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY = 10;
export function K8sExpandedRow<T>({
rowKey,
groupMeta,
record,
entity,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
renderRowData,
}: K8sExpandedRowProps<T>): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [queryFilters, setFilters] = useInfraMonitoringFilters();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
[rowKey],
);
const [orderBy, setOrderBy] = useQueryState(
orderByParamKey,
parseAsJsonNoValidate<SortState | null>()
.withDefault(null as never)
.withOptions({
history: 'push',
}),
);
useEffect(() => {
return (): void => {
void setOrderBy(null);
};
}, [setOrderBy]);
const [columnsDefinitions, columnsHidden] =
useInfraMonitoringTableColumnsForPage(entity);
const storageKey = `k8s-${entity}-columns-expanded`;
const hiddenColumnIdsForNested = useMemo(
() =>
columnsDefinitions
.filter((col) => col.behavior === 'hidden-on-collapse')
.map((col) => col.id),
[columnsDefinitions],
);
const nestedColumns = useMemo(
() =>
tableColumns.filter(
(c) =>
!columnsHidden.includes(c.key?.toString() || '') &&
!hiddenColumnIdsForNested.includes(c.key?.toString() || ''),
),
[tableColumns, columnsHidden, hiddenColumnIdsForNested],
);
const createFiltersForRecord = useCallback((): NonNullable<
IBuilderQuery['filters']
@@ -93,64 +97,45 @@ export function K8sExpandedRow<T>({
op: 'and',
};
const metaKeys = groupMeta ?? {};
const { groupedByMeta } = record;
for (const key of Object.keys(metaKeys)) {
const value = metaKeys[key];
// Skip empty values to avoid creating invalid filters
if (value === '' || value === undefined || value === null) {
continue;
}
for (const key of Object.keys(groupedByMeta)) {
baseFilters.items.push({
key: {
key,
type: 'resource',
type: null,
},
op: '=',
value,
value: groupedByMeta[key],
id: key,
});
}
return baseFilters;
}, [queryFilters?.items, groupMeta]);
}, [queryFilters?.items, record]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
entity,
return getAutoRefreshQueryKey(selectedTime, [
'k8sExpandedRow',
JSON.stringify(groupMeta),
rowKey,
record.key,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
groupMeta,
rowKey,
queryFilters,
orderBy,
]);
]);
}, [selectedTime, record.key, queryFilters, orderBy]);
const { data, isLoading, isError } = useQuery({
const { data, isFetching, isLoading, isError } = useQuery({
queryKey,
queryFn: async ({ signal }) => {
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return await fetchListData(
return fetchListData(
{
limit: EXPANDED_ROW_LIMIT,
limit: MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
@@ -161,45 +146,48 @@ export function K8sExpandedRow<T>({
signal,
);
},
staleTime: 1000 * 60 * 30,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const expandedData = data?.data ?? [];
const formattedData = useMemo(() => {
if (!data?.data) {
return undefined;
}
const handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
setSelectedItem(itemKey);
},
[setSelectedItem],
);
const rows = data.data.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map((row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
});
}, [data?.data, renderRowData, groupBy]);
const openRecordInNewTab = (rowRecord: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', rowRecord.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setGroupBy([]);
setCurrentPage(1);
setFilters(filters);
if (orderBy) {
setMainOrderBy(orderBy);
}
setCurrentPage(1);
setGroupBy([]);
setOrderBy(null);
};
const total = data?.total ?? 0;
const hasMoreItems = total > EXPANDED_ROW_LIMIT;
const footerContent = hasMoreItems ? (
<Button
type="button"
color="secondary"
variant="outlined"
className={styles.viewAllButton}
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>
View All
</Button>
) : null;
return (
<div
className={styles.expandedTableContainer}
@@ -209,30 +197,50 @@ export function K8sExpandedRow<T>({
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
<div data-testid="expanded-table">
<TanStackTableStateProvider>
<TanStackTable<T>
data={expandedData}
columns={tableColumns}
columnStorageKey={storageKey}
isLoading={isLoading}
getRowKey={getRowKey}
getItemKey={getItemKey}
onRowClick={handleRowClick}
enableQueryParams={{
orderBy: orderByParamKey,
{isFetching || isLoading ? (
<LoadingContainer />
) : (
<div data-testid="expanded-table">
<Table
columns={nestedColumns}
dataSource={formattedData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
tableScrollerProps={{
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize="medium"
onRow={(
rowRecord: K8sRenderedRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (isModifierKeyPressed(event)) {
openRecordInNewTab(rowRecord);
return;
}
setSelectedItem(rowRecord.itemKey);
},
className: styles.expandedClickableRow,
})}
/>
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (
<div className={styles.expandedTableFooter}>{footerContent}</div>
)}
</div>
{data?.total && data?.total > MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY && (
<div className={styles.expandedTableFooter}>
<Button
type="default"
size="small"
className={styles.viewAllButton}
onClick={handleViewAllClick}
>
<CornerDownRight size={14} />
View All
</Button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,98 +1,57 @@
import { useMemo } from 'react';
import { Button, DrawerWrapper } from '@signozhq/ui';
import { InfraMonitoringEntity } from '../constants';
import {
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sFiltersSidePanel.module.scss';
type ColumnPickerItem = {
id: string;
label: string;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
/**
* Converts TableColumnDef to column picker item format
*/
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: typeof col.header === 'string' ? col.header : col.id,
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
function K8sFiltersSidePanel<TData>({
function K8sFiltersSidePanel({
open,
onClose,
columns,
storageKey,
entity,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
entity: InfraMonitoringEntity;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
const addColumn = useInfraMonitoringTableColumnsStore(
(state) => state.addColumn,
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
const removeColumn = useInfraMonitoringTableColumnsStore(
(state) => state.removeColumn,
);
const hiddenColumns = useMemo(
() =>
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
[columnPickerItems, hiddenColumnIds],
);
const handleRemoveColumn = (columnId: string): void => {
hideColumn(storageKey, columnId);
};
const handleAddColumn = (columnId: string): void => {
showColumn(storageKey, columnId);
};
const [columns, columnsHidden] = useInfraMonitoringTableColumnsForPage(entity);
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{addedColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => handleRemoveColumn(column.id)}
>
{column.label}
</Button>
</div>
))}
{columns
.filter(
(column) =>
!columnsHidden.includes(column.id) &&
column.behavior !== 'hidden-on-collapse',
)
.map((column) => (
<div className={styles.columnItem} key={column.value}>
{/*<GripVertical size={16} /> TODO: Add support back when update the table component */}
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => removeColumn(entity, column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
@@ -100,21 +59,23 @@ function K8sFiltersSidePanel<TData>({
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{hiddenColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => handleAddColumn(column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
{columns
.filter((column) => columnsHidden.includes(column.id))
.map((column) => (
<div className={styles.columnItem} key={column.value}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => addColumn(entity, column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);

View File

@@ -1,17 +0,0 @@
import { getGroupByEl } from './utils';
import { useInfraMonitoringGroupBy } from '../hooks';
interface K8sEntityWithMeta {
meta?: Record<string, string>;
}
function K8sGroupCell<T extends K8sEntityWithMeta>({
row,
}: {
row: T;
}): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
return getGroupByEl(row, groupBy) as JSX.Element;
}
export default K8sGroupCell;

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { initialQueriesMap } from 'constants/queryBuilder';
@@ -20,31 +19,27 @@ import {
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
interface K8sHeaderProps<TData> {
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
showAutoRefresh: boolean;
columns: TableColumnDef<TData>[];
columnStorageKey: string;
}
function K8sHeader<TData>({
function K8sHeader({
controlListPrefix,
entity,
showAutoRefresh,
columns,
columnStorageKey,
}: K8sHeaderProps<TData>): JSX.Element {
}: K8sHeaderProps): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const currentQuery = initialQueriesMap[DataSource.METRICS];
@@ -82,7 +77,7 @@ function K8sHeader<TData>({
entityVersion: '',
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
setUrlFilters(value || null);
@@ -212,6 +207,7 @@ function K8sHeader<TData>({
variant="ghost"
size="icon"
color="none"
disabled={groupBy?.length > 0}
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
>
@@ -221,8 +217,7 @@ function K8sHeader<TData>({
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
columns={columns}
storageKey={columnStorageKey}
entity={entity}
onClose={onClickOutside}
/>
</div>

View File

@@ -13,14 +13,15 @@ export type K8sBaseFilters = {
orderBy?: OrderBySchemaType;
};
/**
* Type for table row data with required key fields.
* Used when rendering raw data in the table.
*/
export type K8sTableRowData<T> = T & {
export type K8sRenderedRowData = {
/**
* The unique ID for the row
*/
key: string;
id: string;
/**
* The ID to the selectedItem
*/
itemKey: string;
/** Metadata about which attributes were used for grouping */
groupedByMeta?: Record<string, string>;
groupedByMeta: Record<string, string>;
[key: string]: unknown;
};

View File

@@ -22,32 +22,30 @@ const dotToUnder: Record<string, string> = {
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
};
export function getGroupedByMeta<T extends { meta?: Record<string, string> }>(
export function getGroupedByMeta<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: BaseAutocompleteData[],
): Record<string, string> {
const result: Record<string, string> = {};
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
result[rawKey] = (meta[metaKey] || meta[rawKey]) ?? '';
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
result[rawKey] = (itemData.meta[metaKey] || itemData.meta[rawKey]) ?? '';
});
return result;
}
export function getRowKey<T extends { meta?: Record<string, string> }>(
export function getRowKey<T extends { meta: Record<string, string> }>(
itemData: T,
getItemIdentifier: () => string,
groupBy: BaseAutocompleteData[],
): string {
const nodeIdentifier = getItemIdentifier();
const meta = itemData.meta ?? {};
if (groupBy.length === 0) {
return nodeIdentifier || JSON.stringify(meta);
return nodeIdentifier || JSON.stringify(itemData.meta);
}
const groupedMeta = getGroupedByMeta(itemData, groupBy);
@@ -63,32 +61,30 @@ export function getRowKey<T extends { meta?: Record<string, string> }>(
return nodeIdentifier;
}
return JSON.stringify(meta);
return JSON.stringify(itemData.meta);
}
export function getGroupByEl<T extends { meta?: Record<string, string> }>(
export function getGroupByEl<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode {
const groupByValues: string[] = [];
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
const value = itemData.meta[metaKey] || itemData.meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value, index) => (
{groupByValues.map((value) => (
<Badge
// oxlint-disable-next-line react/no-array-index-key
key={`${index}-${value}`}
key={value}
color="secondary"
className={styles.itemDataGroupTagItem}
>

View File

@@ -19,9 +19,9 @@ import {
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
getK8sClusterItemKey,
getK8sClusterRowKey,
k8sClustersColumns,
k8sClustersColumnsConfig,
k8sClustersRenderRowData,
} from './table.config';
function K8sClustersList({
@@ -91,10 +91,10 @@ function K8sClustersList({
<K8sBaseList<K8sClusterData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.CLUSTERS}
tableColumnsDefinitions={k8sClustersColumns}
tableColumns={k8sClustersColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sClusterRowKey}
getItemKey={getK8sClusterItemKey}
renderRowData={k8sClustersRenderRowData}
eventCategory={InfraMonitoringEvents.Cluster}
/>

View File

@@ -1,26 +1,77 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sClusterData, K8sClustersListPayload } from './api';
import { Boxes } from 'lucide-react';
export function getK8sClusterRowKey(cluster: K8sClusterData): string {
return (
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
cluster.meta.k8s_cluster_name
);
import styles from './table.module.scss';
export interface K8sClustersRowData {
key: string;
itemKey: string;
clusterUID: string;
clusterName: React.ReactNode;
cpu: React.ReactNode;
cpu_allocatable: React.ReactNode;
memory: React.ReactNode;
memory_allocatable: React.ReactNode;
groupedByMeta?: Record<string, string>;
}
export function getK8sClusterItemKey(cluster: K8sClusterData): string {
return cluster.meta.k8s_cluster_name;
}
export const k8sClustersColumns: IEntityColumn[] = [
{
label: 'Cluster Group',
value: 'clusterGroup',
id: 'clusterGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Alloc (cores)',
value: 'cpu_allocatable',
id: 'cpu_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Alloc (bytes)',
value: 'memory_allocatable',
id: 'memory_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
filters: {
@@ -30,110 +81,103 @@ export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
export const k8sClustersColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
id: 'clusterGroup',
header: (): React.ReactNode => <EntityGroupHeader title="CLUSTER GROUP" />,
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'clusterName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
icon={<Boxes data-hide-expanded="true" size={14} />}
/>
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> CLUSTER GROUP
</div>
),
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return (
<Tooltip title={clusterName}>
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
</Tooltip>
);
},
dataIndex: 'clusterGroup',
key: 'clusterGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
id: 'cpu_allocatable',
header: 'CPU Alloc (cores)',
accessorFn: (row): number => row.cpuAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={cpuAllocatable}>
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory',
header: 'Memory Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory_allocatable',
header: 'Memory Allocatable',
accessorFn: (row): number => row.memoryAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={memoryAllocatable}>
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sClustersRenderRowData = (
cluster: K8sClusterData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
cluster,
() =>
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
cluster.meta.k8s_cluster_name,
groupBy,
),
itemKey: cluster.meta.k8s_cluster_name,
clusterUID: cluster.clusterUID || cluster.meta.k8s_cluster_uid,
clusterName: (
<Tooltip title={cluster.meta.k8s_cluster_name}>
{cluster.meta.k8s_cluster_name || ''}
</Tooltip>
),
cpu: (
<ValidateColumnValueWrapper value={cluster.cpuUsage}>
{cluster.cpuUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={cluster.memoryUsage}>
{formatBytes(cluster.memoryUsage)}
</ValidateColumnValueWrapper>
),
cpu_allocatable: (
<ValidateColumnValueWrapper value={cluster.cpuAllocatable}>
{cluster.cpuAllocatable}
</ValidateColumnValueWrapper>
),
memory_allocatable: (
<ValidateColumnValueWrapper value={cluster.memoryAllocatable}>
{formatBytes(cluster.memoryAllocatable)}
</ValidateColumnValueWrapper>
),
clusterGroup: getGroupByEl(cluster, groupBy),
...cluster.meta,
groupedByMeta: getGroupedByMeta(cluster, groupBy),
});

View File

@@ -1,5 +1,6 @@
.entityGroupHeader {
display: flex;
align-items: center;
padding-left: var(--spacing-5);
gap: var(--spacing-5);
}

View File

@@ -19,9 +19,9 @@ import {
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
k8sDaemonSetsColumns,
k8sDaemonSetsColumnsConfig,
k8sDaemonSetsRenderRowData,
} from './table.config';
function K8sDaemonSetsList({
@@ -91,10 +91,10 @@ function K8sDaemonSetsList({
<K8sBaseList<K8sDaemonSetsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumnsDefinitions={k8sDaemonSetsColumns}
tableColumns={k8sDaemonSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDaemonSetRowKey}
getItemKey={getK8sDaemonSetItemKey}
renderRowData={k8sDaemonSetsRenderRowData}
eventCategory={InfraMonitoringEvents.DaemonSet}
/>

View File

@@ -1,225 +1,297 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sDaemonSetsData } from './api';
import { Group } from 'lucide-react';
export function getK8sDaemonSetRowKey(daemonSet: K8sDaemonSetsData): string {
return (
daemonSet.daemonSetName ||
daemonSet.meta.k8s_daemonset_name ||
`${daemonSet.meta.k8s_namespace_name}-${daemonSet.meta.k8s_daemonset_name}`
);
}
import styles from './table.module.scss';
export function getK8sDaemonSetItemKey(daemonSet: K8sDaemonSetsData): string {
return daemonSet.meta.k8s_daemonset_name;
}
export const k8sDaemonSetsColumnsConfig: TableColumnDef<K8sDaemonSetsData>[] = [
export const k8sDaemonSetsColumns: IEntityColumn[] = [
{
label: 'DaemonSet Group',
value: 'daemonSetGroup',
id: 'daemonSetGroup',
header: (): React.ReactNode => <EntityGroupHeader title="DAEMONSET GROUP" />,
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'DaemonSet Name',
value: 'daemonsetName',
id: 'daemonsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
icon={<Group data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return (
<Tooltip title={daemonsetName}>
<TanStackTable.Text>{daemonsetName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 100 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_nodes',
id: 'available_nodes',
header: 'Available',
accessorFn: (row): number => row.availableNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const availableNodes = value as number;
return (
<ValidateColumnValueWrapper value={availableNodes}>
<TanStackTable.Text>{availableNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_nodes',
id: 'desired_nodes',
header: 'Desired',
accessorFn: (row): number => row.desiredNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredNodes = value as number;
return (
<ValidateColumnValueWrapper value={desiredNodes}>
<TanStackTable.Text>{desiredNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sDaemonSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> DAEMONSET GROUP
</div>
),
dataIndex: 'daemonSetGroup',
key: 'daemonSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>DaemonSet Name</div>,
dataIndex: 'daemonsetName',
key: 'daemonsetName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_nodes',
key: 'available_nodes',
width: 50,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_nodes',
key: 'desired_nodes',
width: 50,
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
];
export const k8sDaemonSetsRenderRowData = (
entity: K8sDaemonSetsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
entity,
() => entity.daemonSetName || entity.meta.k8s_daemonset_name || '',
groupBy,
),
itemKey: entity.meta.k8s_daemonset_name,
daemonsetName: (
<Tooltip title={entity.meta.k8s_daemonset_name}>
{entity.meta.k8s_daemonset_name || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={entity.meta.k8s_namespace_name}>
{entity.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={entity.cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={entity.cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={entity.cpuUsage}>
{entity.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={entity.memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={entity.memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={entity.memoryUsage}>
{formatBytes(entity.memoryUsage)}
</ValidateColumnValueWrapper>
),
available_nodes: (
<ValidateColumnValueWrapper value={entity.availableNodes}>
{entity.availableNodes}
</ValidateColumnValueWrapper>
),
desired_nodes: (
<ValidateColumnValueWrapper value={entity.desiredNodes}>
{entity.desiredNodes}
</ValidateColumnValueWrapper>
),
daemonSetGroup: getGroupByEl(entity, groupBy),
...entity.meta,
groupedByMeta: getGroupedByMeta(entity, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
k8sDeploymentInitialLogTracesFilter,
} from './constants';
import {
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
k8sDeploymentsColumns,
k8sDeploymentsColumnsConfig,
k8sDeploymentsRenderRowData,
} from './table.config';
function K8sDeploymentsList({
@@ -91,10 +91,10 @@ function K8sDeploymentsList({
<K8sBaseList<K8sDeploymentsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumnsDefinitions={k8sDeploymentsColumns}
tableColumns={k8sDeploymentsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDeploymentRowKey}
getItemKey={getK8sDeploymentItemKey}
renderRowData={k8sDeploymentsRenderRowData}
eventCategory={InfraMonitoringEvents.Deployment}
/>

View File

@@ -1,230 +1,269 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { K8sDeploymentsData } from './api';
import { Computer } from 'lucide-react';
export function getK8sDeploymentRowKey(deployment: K8sDeploymentsData): string {
return deployment.meta.k8s_deployment_name || deployment.deploymentName;
}
import styles from './table.module.scss';
export function getK8sDeploymentItemKey(
export const k8sDeploymentsColumns: IEntityColumn[] = [
{
label: 'Deployment Group',
value: 'deploymentGroup',
id: 'deploymentGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Deployment Name',
value: 'deploymentName',
id: 'deploymentName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_pods',
id: 'available_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_pods',
id: 'desired_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sDeploymentsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> DEPLOYMENT GROUP
</div>
),
dataIndex: 'deploymentGroup',
key: 'deploymentGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>Deployment Name</div>,
dataIndex: 'deploymentName',
key: 'deploymentName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_pods',
key: 'available_pods',
width: 100,
sorter: false,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_pods',
key: 'desired_pods',
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
sorter: true,
align: 'left',
},
];
export const k8sDeploymentsRenderRowData = (
deployment: K8sDeploymentsData,
): string {
return deployment.meta.k8s_deployment_name;
}
export const k8sDeploymentsColumnsConfig: TableColumnDef<K8sDeploymentsData>[] =
[
{
id: 'deploymentGroup',
header: (): React.ReactNode => (
<EntityGroupHeader title="DEPLOYMENT GROUP" />
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 220 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'deploymentName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
icon={<Computer data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 210 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return (
<Tooltip title={deploymentName}>
<TanStackTable.Text>{deploymentName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 220 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'available_pods',
header: 'Available',
accessorFn: (row): number => row.availablePods,
width: { min: 100 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper value={availablePods}>
<TanStackTable.Text>{availablePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_pods',
header: 'Desired',
accessorFn: (row): number => row.desiredPods,
width: { min: 80 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper value={desiredPods}>
<TanStackTable.Text>{desiredPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(deployment, () => deployment.meta.k8s_deployment_name, groupBy),
itemKey: deployment.meta.k8s_deployment_name,
deploymentName: (
<Tooltip title={deployment.meta.k8s_deployment_name}>
{deployment.meta.k8s_deployment_name || ''}
</Tooltip>
),
namespaceName: deployment.meta.k8s_namespace_name,
available_pods: (
<ValidateColumnValueWrapper value={deployment.availablePods}>
{deployment.availablePods}
</ValidateColumnValueWrapper>
),
desired_pods: (
<ValidateColumnValueWrapper value={deployment.desiredPods}>
{deployment.desiredPods}
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={deployment.cpuUsage}>
{deployment.cpuUsage}
</ValidateColumnValueWrapper>
),
cpu_request: (
<ValidateColumnValueWrapper value={deployment.cpuRequest}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper value={deployment.cpuLimit}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={deployment.memoryUsage}>
{formatBytes(deployment.memoryUsage)}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper value={deployment.memoryRequest}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper value={deployment.memoryLimit}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
deploymentGroup: getGroupByEl(deployment, groupBy),
...deployment.meta,
groupedByMeta: getGroupedByMeta(deployment, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -43,7 +43,7 @@ import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
import {
useInfraMonitoringCategory,
useInfraMonitoringFiltersK8s,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
} from './hooks';
@@ -60,7 +60,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();

View File

@@ -19,9 +19,9 @@ import {
k8sJobInitialLogTracesFilter,
} from './constants';
import {
getK8sJobItemKey,
getK8sJobRowKey,
k8sJobsColumns,
k8sJobsColumnsConfig,
k8sJobsRenderRowData,
} from './table.config';
function K8sJobsList({
@@ -91,10 +91,10 @@ function K8sJobsList({
<K8sBaseList<K8sJobsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.JOBS}
tableColumnsDefinitions={k8sJobsColumns}
tableColumns={k8sJobsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sJobRowKey}
getItemKey={getK8sJobItemKey}
renderRowData={k8sJobsRenderRowData}
eventCategory={InfraMonitoringEvents.Job}
/>

View File

@@ -1,249 +1,330 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sJobsData } from './api';
import { Bolt } from 'lucide-react';
export function getK8sJobRowKey(job: K8sJobsData): string {
return job.jobName || job.meta.k8s_job_name || '';
}
import styles from './table.module.scss';
export function getK8sJobItemKey(job: K8sJobsData): string {
return job.meta.k8s_job_name;
}
export const k8sJobsColumnsConfig: TableColumnDef<K8sJobsData>[] = [
export const k8sJobsColumns: IEntityColumn[] = [
{
label: 'Job Group',
value: 'jobGroup',
id: 'jobGroup',
header: (): React.ReactNode => <EntityGroupHeader title="JOB GROUP" />,
accessorFn: (row): string => row.meta.k8s_job_name || '',
width: { min: 270 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Job Name',
value: 'jobName',
id: 'jobName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
icon={<Bolt data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_job_name || '',
width: { min: 260 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const jobName = value as string;
return (
<Tooltip title={jobName}>
<TanStackTable.Text>{jobName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Successful',
value: 'successful_pods',
id: 'successful_pods',
header: 'Successful',
accessorFn: (row): number => row.successfulPods,
width: { min: 120 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const successfulPods = value as number;
return (
<ValidateColumnValueWrapper value={successfulPods}>
<TanStackTable.Text>{successfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Failed',
value: 'failed_pods',
id: 'failed_pods',
header: 'Failed',
accessorFn: (row): number => row.failedPods,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const failedPods = value as number;
return (
<ValidateColumnValueWrapper value={failedPods}>
<TanStackTable.Text>{failedPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired Successful',
value: 'desired_successful_pods',
id: 'desired_successful_pods',
header: 'Desired Successful',
accessorFn: (row): number => row.desiredSuccessfulPods,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredSuccessfulPods = value as number;
return (
<ValidateColumnValueWrapper value={desiredSuccessfulPods}>
<TanStackTable.Text>{desiredSuccessfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Active',
value: 'active_pods',
id: 'active_pods',
header: 'Active',
accessorFn: (row): number => row.activePods,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const activePods = value as number;
return (
<ValidateColumnValueWrapper value={activePods}>
<TanStackTable.Text>{activePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sJobsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> JOB GROUP
</div>
),
dataIndex: 'jobGroup',
key: 'jobGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>Job Name</div>,
dataIndex: 'jobName',
key: 'jobName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Successful</div>,
dataIndex: 'successful_pods',
key: 'successful_pods',
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Failed</div>,
dataIndex: 'failed_pods',
key: 'failed_pods',
sorter: true,
align: 'left',
},
{
title: <div>Desired Successful</div>,
dataIndex: 'desired_successful_pods',
key: 'desired_successful_pods',
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Active</div>,
dataIndex: 'active_pods',
key: 'active_pods',
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
];
export const k8sJobsRenderRowData = (
job: K8sJobsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(job, () => job.jobName || job.meta.k8s_job_name || '', groupBy),
itemKey: job.meta.k8s_job_name,
jobName: (
<Tooltip title={job.meta.k8s_job_name}>{job.meta.k8s_job_name || ''}</Tooltip>
),
namespaceName: (
<Tooltip title={job.meta.k8s_namespace_name}>
{job.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={job.cpuRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={job.cpuLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={job.cpuUsage}>
{job.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={job.memoryRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={job.memoryLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={job.memoryUsage}>
{formatBytes(job.memoryUsage)}
</ValidateColumnValueWrapper>
),
successful_pods: (
<ValidateColumnValueWrapper value={job.successfulPods}>
{job.successfulPods}
</ValidateColumnValueWrapper>
),
desired_successful_pods: (
<ValidateColumnValueWrapper value={job.desiredSuccessfulPods}>
{job.desiredSuccessfulPods}
</ValidateColumnValueWrapper>
),
failed_pods: (
<ValidateColumnValueWrapper value={job.failedPods}>
{job.failedPods}
</ValidateColumnValueWrapper>
),
active_pods: (
<ValidateColumnValueWrapper value={job.activePods}>
{job.activePods}
</ValidateColumnValueWrapper>
),
jobGroup: getGroupByEl(job, groupBy),
...job.meta,
groupedByMeta: getGroupedByMeta(job, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
namespaceWidgetInfo,
} from './constants';
import {
getK8sNamespaceItemKey,
getK8sNamespaceRowKey,
k8sNamespacesColumns,
k8sNamespacesColumnsConfig,
k8sNamespacesRenderRowData,
} from './table.config';
function K8sNamespacesList({
@@ -91,10 +91,10 @@ function K8sNamespacesList({
<K8sBaseList<K8sNamespacesData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NAMESPACES}
tableColumnsDefinitions={k8sNamespacesColumns}
tableColumns={k8sNamespacesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNamespaceRowKey}
getItemKey={getK8sNamespaceItemKey}
renderRowData={k8sNamespacesRenderRowData}
eventCategory={InfraMonitoringEvents.Namespace}
/>

View File

@@ -1,21 +1,68 @@
import { Tooltip } from 'antd';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sNamespacesData, K8sNamespacesListPayload } from './api';
import { FilePenLine } from 'lucide-react';
export function getK8sNamespaceRowKey(namespace: K8sNamespacesData): string {
return namespace.namespaceName || namespace.meta.k8s_namespace_name;
import styles from './table.module.scss';
export interface K8sNamespacesRowData {
key: string;
itemKey: string;
namespaceUID: string;
namespaceName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
memory: React.ReactNode;
groupedByMeta?: Record<string, string>;
}
export function getK8sNamespaceItemKey(namespace: K8sNamespacesData): string {
return namespace.meta.k8s_namespace_name;
}
export const k8sNamespacesColumns: IEntityColumn[] = [
{
label: 'Namespace Group',
value: 'namespaceGroup',
id: 'namespaceGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sNamespacesListQuery = (): K8sNamespacesListPayload => ({
filters: {
@@ -25,90 +72,84 @@ export const getK8sNamespacesListQuery = (): K8sNamespacesListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sNamespacesColumnsConfig: TableColumnDef<K8sNamespacesData>[] = [
export const k8sNamespacesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
id: 'namespaceGroup',
header: (): React.ReactNode => <EntityGroupHeader title="NAMESPACE GROUP" />,
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'namespaceName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Namespace Name"
icon={<FilePenLine data-hide-expanded="true" size={14} />}
/>
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> NAMESPACE GROUP
</div>
),
accessorFn: (row): string => row.namespaceName || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
dataIndex: 'namespaceGroup',
key: 'namespaceGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
id: 'clusterName',
header: 'Cluster Name',
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 100,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
sorter: true,
align: 'left',
},
];
export const k8sNamespacesRenderRowData = (
namespace: K8sNamespacesData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
namespace,
() => namespace.namespaceName || namespace.meta.k8s_namespace_name,
groupBy,
),
itemKey: namespace.meta.k8s_namespace_name,
namespaceUID: namespace.namespaceName,
namespaceName: (
<Tooltip title={namespace.namespaceName}>
{namespace.namespaceName || ''}
</Tooltip>
),
clusterName: namespace.meta.k8s_cluster_name,
cpu: (
<ValidateColumnValueWrapper value={namespace.cpuUsage}>
{namespace.cpuUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={namespace.memoryUsage}>
{formatBytes(namespace.memoryUsage)}
</ValidateColumnValueWrapper>
),
namespaceGroup: getGroupByEl(namespace, groupBy),
...namespace.meta,
groupedByMeta: getGroupedByMeta(namespace, groupBy),
});

View File

@@ -0,0 +1,6 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}

View File

@@ -19,9 +19,9 @@ import {
nodeWidgetInfo,
} from './constants';
import {
getK8sNodeItemKey,
getK8sNodeRowKey,
k8sNodesColumns,
k8sNodesColumnsConfig,
k8sNodesRenderRowData,
} from './table.config';
function K8sNodesList({
@@ -91,10 +91,10 @@ function K8sNodesList({
<K8sBaseList<K8sNodeData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NODES}
tableColumnsDefinitions={k8sNodesColumns}
tableColumns={k8sNodesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNodeRowKey}
getItemKey={getK8sNodeItemKey}
renderRowData={k8sNodesRenderRowData}
eventCategory={InfraMonitoringEvents.Node}
/>

View File

@@ -1,22 +1,86 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sNodeData, K8sNodesListPayload } from './api';
import { Workflow } from 'lucide-react';
export function getK8sNodeRowKey(node: K8sNodeData): string {
return node.nodeUID || node.meta.k8s_node_uid || node.meta.k8s_node_name;
import styles from './table.module.scss';
export interface K8sNodesRowData {
key: string;
itemKey: string;
nodeUID: string;
nodeName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
cpu_allocatable: React.ReactNode;
memory: React.ReactNode;
memory_allocatable: React.ReactNode;
groupedByMeta?: any;
}
export function getK8sNodeItemKey(node: K8sNodeData): string {
return node.meta.k8s_node_name;
}
export const k8sNodesColumns: IEntityColumn[] = [
{
label: 'Node Group',
value: 'nodeGroup',
id: 'nodeGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Node Name',
value: 'nodeName',
id: 'nodeName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Alloc (cores)',
value: 'cpu_allocatable',
id: 'cpu_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Alloc (bytes)',
value: 'memory_allocatable',
id: 'memory_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
filters: {
@@ -26,120 +90,110 @@ export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sNodesColumnsConfig: TableColumnDef<K8sNodeData>[] = [
export const k8sNodesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
id: 'nodeGroup',
header: (): React.ReactNode => <EntityGroupHeader title="NODE GROUP" />,
accessorFn: (row): string => row.meta.k8s_node_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'nodeName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Node Name"
icon={<Workflow data-hide-expanded="true" size={14} />}
/>
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> NODE GROUP
</div>
),
accessorFn: (row): string => row.meta.k8s_node_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const nodeName = value as string;
return (
<Tooltip title={nodeName}>
<TanStackTable.Text>{nodeName}</TanStackTable.Text>
</Tooltip>
);
},
dataIndex: 'nodeGroup',
key: 'nodeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
id: 'clusterName',
header: 'Cluster Name',
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
title: <div>Node Name</div>,
dataIndex: 'nodeName',
key: 'nodeName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.nodeCPUUsage,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
id: 'cpu_allocatable',
header: 'CPU Alloc (cores)',
accessorFn: (row): number => row.nodeCPUAllocatable,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={cpuAllocatable}>
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory',
header: 'Memory Usage (WSS)',
accessorFn: (row): number => row.nodeMemoryUsage,
width: { min: 240, default: 240 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory_allocatable',
header: 'Memory Allocatable',
accessorFn: (row): number => row.nodeMemoryAllocatable,
width: { min: 240, default: 240 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={memoryAllocatable}>
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sNodesRenderRowData = (
node: K8sNodeData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
node,
() => node.nodeUID || node.meta.k8s_node_uid || node.meta.k8s_node_name,
groupBy,
),
itemKey: node.meta.k8s_node_name,
nodeUID: node.nodeUID || node.meta.k8s_node_uid,
nodeName: (
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name || ''}
</Tooltip>
),
clusterName: node.meta.k8s_cluster_name,
cpu: (
<ValidateColumnValueWrapper value={node.nodeCPUUsage}>
{node.nodeCPUUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={node.nodeMemoryUsage}>
{formatBytes(node.nodeMemoryUsage)}
</ValidateColumnValueWrapper>
),
cpu_allocatable: (
<ValidateColumnValueWrapper value={node.nodeCPUAllocatable}>
{node.nodeCPUAllocatable}
</ValidateColumnValueWrapper>
),
memory_allocatable: (
<ValidateColumnValueWrapper value={node.nodeMemoryAllocatable}>
{formatBytes(node.nodeMemoryAllocatable)}
</ValidateColumnValueWrapper>
),
nodeGroup: getGroupByEl(node, groupBy),
...node.meta,
groupedByMeta: getGroupedByMeta(node, groupBy),
});

View File

@@ -0,0 +1,6 @@
.entityGroupHeader {
display: flex;
align-items: center;
padding-left: var(--spacing-5);
gap: var(--spacing-5);
}

View File

@@ -19,9 +19,9 @@ import {
podWidgetInfo,
} from './constants';
import {
getK8sPodItemKey,
getK8sPodRowKey,
k8sPodColumns,
k8sPodColumnsConfig,
k8sPodRenderRowData,
} from './table.config';
function K8sPodsList({
@@ -91,10 +91,10 @@ function K8sPodsList({
<K8sBaseList<K8sPodsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.PODS}
tableColumnsDefinitions={k8sPodColumns}
tableColumns={k8sPodColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sPodRowKey}
getItemKey={getK8sPodItemKey}
renderRowData={k8sPodRenderRowData}
eventCategory={InfraMonitoringEvents.Pod}
/>

View File

@@ -1,207 +1,328 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import React from 'react';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sPodsData } from './api';
import { Container } from 'lucide-react';
export function getK8sPodRowKey(pod: K8sPodsData): string {
return pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name;
import styles from './table.module.scss';
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
restarts: React.ReactNode;
groupedByMeta?: any;
}
export function getK8sPodItemKey(pod: K8sPodsData): string {
return pod.podUID;
}
export const k8sPodColumnsConfig: TableColumnDef<K8sPodsData>[] = [
export const k8sPodColumns: IEntityColumn[] = [
{
label: 'Pod Group',
value: 'podGroup',
id: 'podGroup',
header: (): React.ReactNode => <EntityGroupHeader title="POD GROUP" />,
accessorFn: (row): string => row.meta.k8s_pod_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Pod name',
value: 'podName',
id: 'podName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Pod Name"
icon={<Container data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_pod_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const podName = value as string;
return (
<Tooltip title={podName}>
<TanStackTable.Text>{podName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.podCPURequest,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.podCPULimit,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.podCPU,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.podMemoryRequest,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.podMemoryLimit,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.podMemory,
width: { min: 210, default: '100%' },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Namespace name',
value: 'namespace',
id: 'namespace',
header: 'Namespace',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 100 },
enableSort: false,
canBeHidden: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
behavior: 'always-visible',
},
{
label: 'Node name',
value: 'node',
id: 'node',
header: 'Node',
accessorFn: (row): string => row.meta.k8s_node_name || '',
width: { default: 100 },
enableSort: false,
canBeHidden: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
behavior: 'always-visible',
},
{
label: 'Cluster name',
value: 'cluster',
id: 'cluster',
header: 'Cluster',
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { default: 100 },
enableSort: false,
canBeHidden: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
behavior: 'always-visible',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// label: 'Restarts',
// value: 'restarts',
// id: 'restarts',
// canRemove: false,
// },
];
export const k8sPodColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> POD GROUP
</div>
),
dataIndex: 'podGroup',
key: 'podGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div>Pod Name</div>,
dataIndex: 'podName',
key: 'podName',
width: 180,
ellipsis: true,
sorter: false,
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Namespace</div>,
dataIndex: 'namespace',
key: 'namespace',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Node</div>,
dataIndex: 'node',
key: 'node',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Cluster</div>,
dataIndex: 'cluster',
key: 'cluster',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// title: (
// <div className="column-header">
// <Tooltip title="Container Restarts">Restarts</Tooltip>
// </div>
// ),
// dataIndex: 'restarts',
// key: 'restarts',
// width: 40,
// ellipsis: true,
// sorter: true,
// align: 'left',
// className: `column ${columnProgressBarClassName}`,
// },
];
export const k8sPodRenderRowData = (
pod: K8sPodsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
pod,
() => pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name,
groupBy,
),
itemKey: pod.podUID,
podName: (
<Tooltip title={pod.meta.k8s_pod_name || ''}>
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper
value={pod.podCPURequest}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPURequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={pod.podCPULimit}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPULimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={pod.podCPU}>
{pod.podCPU}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={pod.podMemoryRequest}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={pod.podMemoryLimit}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={pod.podMemory}>
{formatBytes(pod.podMemory)}
</ValidateColumnValueWrapper>
),
restarts: (
<ValidateColumnValueWrapper value={pod.restartCount}>
{pod.restartCount}
</ValidateColumnValueWrapper>
),
namespace: pod.meta.k8s_namespace_name,
node: pod.meta.k8s_node_name,
cluster: pod.meta.k8s_cluster_name,
meta: pod.meta,
podGroup: getGroupByEl(pod, groupBy),
...pod.meta,
groupedByMeta: getGroupedByMeta(pod, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
statefulSetWidgetInfo,
} from './constants';
import {
getK8sStatefulSetItemKey,
getK8sStatefulSetRowKey,
k8sStatefulSetsColumns,
k8sStatefulSetsColumnsConfig,
k8sStatefulSetsRenderRowData,
} from './table.config';
function K8sStatefulSetsList({
@@ -91,10 +91,10 @@ function K8sStatefulSetsList({
<K8sBaseList<K8sStatefulSetsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.STATEFULSETS}
tableColumnsDefinitions={k8sStatefulSetsColumns}
tableColumns={k8sStatefulSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sStatefulSetRowKey}
getItemKey={getK8sStatefulSetItemKey}
renderRowData={k8sStatefulSetsRenderRowData}
eventCategory={InfraMonitoringEvents.StatefulSet}
/>

View File

@@ -1,239 +1,295 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sStatefulSetsData } from './api';
import { ArrowUpDown } from 'lucide-react';
export function getK8sStatefulSetRowKey(
import styles from './table.module.scss';
export const k8sStatefulSetsColumns: IEntityColumn[] = [
{
label: 'StatefulSet Group',
value: 'statefulSetGroup',
id: 'statefulSetGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'StatefulSet Name',
value: 'statefulsetName',
id: 'statefulsetName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_pods',
id: 'available_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_pods',
id: 'desired_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sStatefulSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> STATEFULSET GROUP
</div>
),
dataIndex: 'statefulSetGroup',
key: 'statefulSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>StatefulSet Name</div>,
dataIndex: 'statefulsetName',
key: 'statefulsetName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_pods',
key: 'available_pods',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_pods',
key: 'desired_pods',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sStatefulSetsRenderRowData = (
statefulSet: K8sStatefulSetsData,
): string {
return (
statefulSet.statefulSetName || statefulSet.meta.k8s_statefulset_name || ''
);
}
export function getK8sStatefulSetItemKey(
statefulSet: K8sStatefulSetsData,
): string {
return statefulSet.meta.k8s_statefulset_name;
}
export const k8sStatefulSetsColumnsConfig: TableColumnDef<K8sStatefulSetsData>[] =
[
{
id: 'statefulSetGroup',
header: (): React.ReactNode => (
<EntityGroupHeader title="STATEFULSET GROUP" />
),
accessorFn: (row): string => row.meta.k8s_statefulset_name || '',
width: { min: 210 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'statefulsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="StatefulSet Name"
icon={<ArrowUpDown data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_statefulset_name || '',
width: { min: 200 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const statefulsetName = value as string;
return (
<Tooltip title={statefulsetName}>
<TanStackTable.Text>{statefulsetName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 150 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'available_pods',
header: 'Available',
accessorFn: (row): number => row.availablePods,
width: { min: 100, default: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper value={availablePods}>
<TanStackTable.Text>{availablePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_pods',
header: 'Desired',
accessorFn: (row): number => row.desiredPods,
width: { min: 100, default: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper value={desiredPods}>
<TanStackTable.Text>{desiredPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
statefulSet,
() =>
statefulSet.statefulSetName || statefulSet.meta.k8s_statefulset_name || '',
groupBy,
),
itemKey: statefulSet.meta.k8s_statefulset_name,
statefulsetName: (
<Tooltip title={statefulSet.meta.k8s_statefulset_name}>
{statefulSet.meta.k8s_statefulset_name || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={statefulSet.meta.k8s_namespace_name}>
{statefulSet.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={statefulSet.cpuRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={statefulSet.cpuLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={statefulSet.cpuUsage}>
{statefulSet.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={statefulSet.memoryRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={statefulSet.memoryLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={statefulSet.memoryUsage}>
{formatBytes(statefulSet.memoryUsage)}
</ValidateColumnValueWrapper>
),
available_pods: (
<ValidateColumnValueWrapper value={statefulSet.availablePods}>
{statefulSet.availablePods}
</ValidateColumnValueWrapper>
),
desired_pods: (
<ValidateColumnValueWrapper value={statefulSet.desiredPods}>
{statefulSet.desiredPods}
</ValidateColumnValueWrapper>
),
statefulSetGroup: getGroupByEl(statefulSet, groupBy),
...statefulSet.meta,
groupedByMeta: getGroupedByMeta(statefulSet, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
display: flex;
align-items: center;
padding-left: var(--spacing-5);
gap: var(--spacing-5);
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
volumeWidgetInfo,
} from './constants';
import {
getK8sVolumeItemKey,
getK8sVolumeRowKey,
k8sVolumesColumns,
k8sVolumesColumnsConfig,
k8sVolumesRenderRowData,
} from './table.config';
function K8sVolumesList({
@@ -91,10 +91,10 @@ function K8sVolumesList({
<K8sBaseList<K8sVolumesData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.VOLUMES}
tableColumnsDefinitions={k8sVolumesColumns}
tableColumns={k8sVolumesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sVolumeRowKey}
getItemKey={getK8sVolumeItemKey}
renderRowData={k8sVolumesRenderRowData}
eventCategory={InfraMonitoringEvents.Volumes}
/>

View File

@@ -1,131 +1,164 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sVolumesData } from './api';
import { HardDrive } from 'lucide-react';
export function getK8sVolumeRowKey(volume: K8sVolumesData): string {
return (
volume.persistentVolumeClaimName ||
volume.meta.k8s_persistentvolumeclaim_name ||
''
);
}
import styles from './table.module.scss';
export function getK8sVolumeItemKey(volume: K8sVolumesData): string {
return volume.persistentVolumeClaimName;
}
export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
export const k8sVolumesColumns: IEntityColumn[] = [
{
label: 'Volume Group',
value: 'volumeGroup',
id: 'volumeGroup',
header: (): React.ReactNode => <EntityGroupHeader title="VOLUME GROUP" />,
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'PVC Name',
value: 'pvcName',
id: 'pvcName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="PVC Name"
icon={<HardDrive data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const pvcName = value as string;
return (
<Tooltip title={pvcName}>
<TanStackTable.Text>{pvcName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { min: 220 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Capacity',
value: 'capacity',
id: 'capacity',
header: 'Volume Capacity',
accessorFn: (row): number => row.volumeCapacity,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const capacity = value as number;
return (
<ValidateColumnValueWrapper value={capacity}>
<TanStackTable.Text>{formatBytes(capacity)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Utilization',
value: 'usage',
id: 'usage',
header: 'Volume Utilization',
accessorFn: (row): number => row.volumeUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const usage = value as number;
return (
<ValidateColumnValueWrapper value={usage}>
<TanStackTable.Text>{formatBytes(usage)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Available',
value: 'available',
id: 'available',
header: 'Volume Available',
accessorFn: (row): number => row.volumeAvailable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const available = value as number;
return (
<ValidateColumnValueWrapper value={available}>
<TanStackTable.Text>{formatBytes(available)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sVolumesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> VOLUME GROUP
</div>
),
dataIndex: 'volumeGroup',
key: 'volumeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>PVC Name</div>,
dataIndex: 'pvcName',
key: 'pvcName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div>Volume Capacity</div>,
dataIndex: 'capacity',
key: 'capacity',
ellipsis: true,
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Volume Utilization</div>,
dataIndex: 'usage',
key: 'usage',
width: 100,
sorter: true,
align: 'left',
},
{
title: <div>Volume Available</div>,
dataIndex: 'available',
key: 'available',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sVolumesRenderRowData = (
volume: K8sVolumesData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
volume,
() =>
volume.persistentVolumeClaimName ||
volume.meta.k8s_persistentvolumeclaim_name ||
'',
groupBy,
),
itemKey: volume.persistentVolumeClaimName,
pvcName: (
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={volume.meta.k8s_namespace_name}>
{volume.meta.k8s_namespace_name || ''}
</Tooltip>
),
available: (
<ValidateColumnValueWrapper value={volume.volumeAvailable}>
{formatBytes(volume.volumeAvailable)}
</ValidateColumnValueWrapper>
),
capacity: (
<ValidateColumnValueWrapper value={volume.volumeCapacity}>
{formatBytes(volume.volumeCapacity)}
</ValidateColumnValueWrapper>
),
usage: (
<ValidateColumnValueWrapper value={volume.volumeUsage}>
{formatBytes(volume.volumeUsage)}
</ValidateColumnValueWrapper>
),
volumeGroup: getGroupByEl(volume, groupBy),
...volume.meta,
groupedByMeta: getGroupedByMeta(volume, groupBy),
});

View File

@@ -0,0 +1,6 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}

View File

@@ -1,11 +1,21 @@
import { render, screen } from '@testing-library/react';
import { EntityProgressBar } from '../components';
import { EventContents } from '../commonUtils';
import { EntityProgressBar, EventContents } from '../commonUtils';
jest.mock('../commonUtils.module.scss', () => ({
__esModule: true,
default: {
entityProgressBar: 'entity-progress-bar-module',
progressBar: 'progress-bar-module',
eventContentContainer: 'event-content-container-module',
},
}));
jest.mock('components/ResizeTable', () => ({
ResizeTable: ({ dataSource }: { dataSource: unknown }): JSX.Element => (
<div data-testid="resize-table">{JSON.stringify(dataSource)}</div>
ResizeTable: ({ className, dataSource }: any): JSX.Element => (
<div data-testid="resize-table" className={className}>
{JSON.stringify(dataSource)}
</div>
),
}));
@@ -15,22 +25,24 @@ jest.mock('container/LogDetailedView/FieldRenderer', () => ({
}));
describe('commonUtils', () => {
it('renders EntityProgressBar with percentage value', () => {
render(<EntityProgressBar value={0.5} type="request" />);
it('renders EntityProgressBar with module classes', () => {
const { container } = render(
<EntityProgressBar value={0.5} type="request" />,
);
expect(container.firstChild).toHaveClass('entity-progress-bar-module');
expect(container.querySelector('.progress-bar-module')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('renders EntityProgressBar with dash for NaN value', () => {
render(<EntityProgressBar value={NaN} type="limit" />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders EventContents with data fields', () => {
it('renders EventContents with the module-scoped table class', () => {
render(
<EventContents data={{ namespace: 'default', cluster: 'prod-cluster' }} />,
);
const resizeTable = screen.getByTestId('resize-table');
expect(resizeTable).toHaveClass('event-content-container-module');
expect(resizeTable).toHaveTextContent('namespace');
expect(resizeTable).toHaveTextContent('prod-cluster');
});

View File

@@ -1,3 +1,15 @@
.entityProgressBar {
display: flex;
align-items: center;
}
.progressBar {
flex: 1;
margin-right: 8px;
margin-bottom: 0;
min-width: 100px;
}
.eventContentContainer {
:global(.ant-table) {
background: var(--l1-background);

View File

@@ -2,12 +2,17 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Table, Tooltip } from 'antd';
import { Progress, Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { getInvalidValueTooltipText, InfraMonitoringEntity } from './constants';
import styles from './commonUtils.module.scss';
@@ -15,10 +20,6 @@ import styles from './commonUtils.module.scss';
* Converts size in bytes to a human-readable string with appropriate units
*/
export function formatBytes(bytes: number, decimals = 2): string {
if (Number.isNaN(bytes) || !Number.isFinite(bytes)) {
return '-';
}
if (bytes === 0) {
return '0 Bytes';
}
@@ -30,6 +31,36 @@ export function formatBytes(bytes: number, decimals = 2): string {
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Wrapper component that renders its children for valid values or renders '-' for invalid values (-1)
*/
export function ValidateColumnValueWrapper({
children,
value,
entity,
attribute,
}: {
children: React.ReactNode;
value: number;
entity?: InfraMonitoringEntity;
attribute?: string;
}): JSX.Element {
if (value === -1) {
let element = <div>-</div>;
if (entity && attribute) {
element = (
<Tooltip title={getInvalidValueTooltipText(entity, attribute)}>
{element}
</Tooltip>
);
}
return element;
}
return <div>{children}</div>;
}
/**
* Returns stroke color for request utilization parameters according to current value
*/
@@ -72,6 +103,35 @@ export function getStrokeColorForLimitUtilization(value: number): string {
return Color.BG_SAKURA_500;
}
export function EntityProgressBar({
value,
type,
}: {
value: number;
type: 'request' | 'limit';
}): JSX.Element {
const percentage = Number((value * 100).toFixed(1));
return (
<div className={styles.entityProgressBar}>
<Progress
percent={percentage}
strokeLinecap="butt"
size="small"
status="normal"
strokeColor={
type === 'limit'
? getStrokeColorForLimitUtilization(value)
: getStrokeColorForRequestUtilization(value)
}
className={styles.progressBar}
showInfo={false}
/>
<Typography.Text style={{ fontSize: '10px' }}>{percentage}%</Typography.Text>
</div>
);
}
export function EventContents({
data,
}: {
@@ -188,3 +248,19 @@ export const filterDuplicateFilters = (
return uniqueFilters;
};
export const getFiltersFromParams = (
searchParams: URLSearchParams,
queryKey: string,
): IBuilderQuery['filters'] | null => {
const filtersFromParams = searchParams.get(queryKey);
if (filtersFromParams) {
try {
const parsed = JSON.parse(filtersFromParams);
return parsed as IBuilderQuery['filters'];
} catch {
return null;
}
}
return null;
};

View File

@@ -1,11 +0,0 @@
.entityProgressBar {
display: flex;
align-items: center;
}
.progressBar {
flex: 1;
margin-right: 8px;
margin-bottom: 0;
min-width: 100px;
}

View File

@@ -1,65 +0,0 @@
import { Progress } from 'antd';
import TanStackTable from 'components/TanStackTableView';
import {
getMemoryProgressColor,
getProgressColor,
} from 'container/InfraMonitoringHosts/constants';
import {
getStrokeColorForLimitUtilization,
getStrokeColorForRequestUtilization,
} from '../commonUtils';
import styles from './EntityProgressBar.module.scss';
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory';
function getStrokeColor(type: EntityProgressBarType, value: number): string {
switch (type) {
case 'limit':
return getStrokeColorForLimitUtilization(value);
case 'request':
return getStrokeColorForRequestUtilization(value);
case 'cpu':
return getProgressColor(Number((value * 100).toFixed(1)));
case 'memory':
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
default:
return getStrokeColorForRequestUtilization(value);
}
}
export function EntityProgressBar({
value,
type,
}: {
value: number;
type: EntityProgressBarType;
}): JSX.Element {
const percentage = Number.isNaN(+value)
? null
: Number((value * 100).toFixed(1));
if (percentage === null) {
return (
<div className={styles.entityProgressBar}>
<TanStackTable.Text>-</TanStackTable.Text>
</div>
);
}
return (
<div className={styles.entityProgressBar}>
<Progress
percent={percentage}
strokeLinecap="butt"
size="small"
status="normal"
strokeColor={getStrokeColor(type, value)}
className={styles.progressBar}
showInfo={false}
/>
<TanStackTable.Text>{percentage}%</TanStackTable.Text>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { Button } from '@signozhq/ui';
import { ChevronDown, ChevronRight } from 'lucide-react';
import styles from './ExpandedButtonWrapper.module.scss';
import { useEffect, useState } from 'react';
export function ExpandButtonWrapper({
toggleExpanded,
isExpanded,
children,
}: {
toggleExpanded: () => void;
isExpanded: boolean;
children?: React.ReactNode;
}): JSX.Element {
// the state is duplicated because it takes a few ms to propagate using isExpanded
// so this local is used to avoid this delay
const [localIsExpanded, setLocalIsExpanded] = useState(isExpanded);
useEffect(() => {
setLocalIsExpanded(isExpanded);
}, [isExpanded]);
return (
<div className={styles.expandButtonContainer}>
<Button
variant="ghost"
color="secondary"
onClick={(e): void => {
e.stopPropagation();
setLocalIsExpanded((v) => !v);
toggleExpanded();
}}
size="icon"
prefix={localIsExpanded ? <ChevronDown /> : <ChevronRight />}
/>
{children}
</div>
);
}

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