mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-04 17:30:34 +01:00
Compare commits
46 Commits
feat/toolt
...
feat/billi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3616022049 | ||
|
|
5d270b716b | ||
|
|
19d9d26051 | ||
|
|
20dd264ac1 | ||
|
|
8a7793794d | ||
|
|
522148362b | ||
|
|
781158ecab | ||
|
|
680bcd08c3 | ||
|
|
0603bd6b27 | ||
|
|
37c57e6c05 | ||
|
|
12a2e63a31 | ||
|
|
453bcc06c4 | ||
|
|
fac5fe6b9e | ||
|
|
5cf0e0fbb9 | ||
|
|
0ad412b844 | ||
|
|
d1957b5eac | ||
|
|
dba9cfd455 | ||
|
|
ed2011a7bb | ||
|
|
68385478c7 | ||
|
|
eb661b7ac7 | ||
|
|
afd6868423 | ||
|
|
8ddf0a13c1 | ||
|
|
16f0d2aa38 | ||
|
|
3af912c586 | ||
|
|
ad7715802b | ||
|
|
b579bdbd7b | ||
|
|
aa64cf7bbf | ||
|
|
2d33b1a743 | ||
|
|
4fbf7de8e1 | ||
|
|
7528b19fd4 | ||
|
|
42e4196aad | ||
|
|
22cdb03702 | ||
|
|
6eca3dc06e | ||
|
|
0631189417 | ||
|
|
ec552b94cc | ||
|
|
ee8d99f1d0 | ||
|
|
bf77e26a86 | ||
|
|
9cd3cf23d7 | ||
|
|
4a44802ebc | ||
|
|
f2aed0d834 | ||
|
|
527d8c0459 | ||
|
|
8fdc91260e | ||
|
|
218c4524b1 | ||
|
|
02dec846eb | ||
|
|
99dadb7247 | ||
|
|
44b41c40de |
@@ -18,11 +18,13 @@ 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"
|
||||
@@ -109,6 +111,9 @@ 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)
|
||||
},
|
||||
|
||||
@@ -17,6 +17,14 @@ 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"
|
||||
@@ -35,9 +43,12 @@ 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"
|
||||
@@ -57,7 +68,10 @@ 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"
|
||||
)
|
||||
@@ -157,6 +171,19 @@ 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)
|
||||
@@ -216,3 +243,15 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ 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:
|
||||
@@ -427,3 +429,10 @@ 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
|
||||
|
||||
@@ -96,6 +96,122 @@ 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:
|
||||
@@ -133,6 +249,10 @@ 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'
|
||||
@@ -145,8 +265,15 @@ components:
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesAuthNProvider:
|
||||
enum:
|
||||
- google_auth
|
||||
- saml
|
||||
- email_password
|
||||
- oidc
|
||||
type: string
|
||||
AuthtypesAuthNProviderInfo:
|
||||
properties:
|
||||
relayStatePath:
|
||||
@@ -169,11 +296,15 @@ components:
|
||||
AuthtypesCallbackAuthNSupport:
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
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'
|
||||
@@ -197,7 +328,7 @@ components:
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -323,7 +454,7 @@ components:
|
||||
AuthtypesPasswordAuthNSupport:
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesPatchableObjects:
|
||||
properties:
|
||||
@@ -2363,6 +2494,9 @@ components:
|
||||
type: object
|
||||
GlobaltypesConfig:
|
||||
properties:
|
||||
ai_assistant_url:
|
||||
nullable: true
|
||||
type: string
|
||||
external_url:
|
||||
type: string
|
||||
identN:
|
||||
@@ -2376,6 +2510,7 @@ components:
|
||||
- external_url
|
||||
- ingestion_url
|
||||
- mcp_url
|
||||
- ai_assistant_url
|
||||
type: object
|
||||
GlobaltypesIdentNConfig:
|
||||
properties:
|
||||
@@ -5665,7 +5800,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ConfigReceiver'
|
||||
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -7042,6 +7177,63 @@ 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
|
||||
|
||||
58
ee/metercollector/baseplatformfeemetercollector/provider.go
Normal file
58
ee/metercollector/baseplatformfeemetercollector/provider.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// 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
|
||||
}
|
||||
107
ee/metercollector/baseplatformfeemetercollector/provider_test.go
Normal file
107
ee/metercollector/baseplatformfeemetercollector/provider_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
276
ee/metercollector/datapointcountmetercollector/provider.go
Normal file
276
ee/metercollector/datapointcountmetercollector/provider.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
276
ee/metercollector/datapointsizemetercollector/provider.go
Normal file
276
ee/metercollector/datapointsizemetercollector/provider.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
276
ee/metercollector/logcountmetercollector/provider.go
Normal file
276
ee/metercollector/logcountmetercollector/provider.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
67
ee/metercollector/logcountmetercollector/provider_test.go
Normal file
67
ee/metercollector/logcountmetercollector/provider_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
276
ee/metercollector/logsizemetercollector/provider.go
Normal file
276
ee/metercollector/logsizemetercollector/provider.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
67
ee/metercollector/logsizemetercollector/provider_test.go
Normal file
67
ee/metercollector/logsizemetercollector/provider_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
255
ee/metercollector/retention/retention.go
Normal file
255
ee/metercollector/retention/retention.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// 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
|
||||
}
|
||||
153
ee/metercollector/retention/retention_test.go
Normal file
153
ee/metercollector/retention/retention_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
276
ee/metercollector/spancountmetercollector/provider.go
Normal file
276
ee/metercollector/spancountmetercollector/provider.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
67
ee/metercollector/spancountmetercollector/provider_test.go
Normal file
67
ee/metercollector/spancountmetercollector/provider_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
276
ee/metercollector/spansizemetercollector/provider.go
Normal file
276
ee/metercollector/spansizemetercollector/provider.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
67
ee/metercollector/spansizemetercollector/provider_test.go
Normal file
67
ee/metercollector/spansizemetercollector/provider_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
630
ee/meterreporter/httpmeterreporter/provider.go
Normal file
630
ee/meterreporter/httpmeterreporter/provider.go
Normal file
@@ -0,0 +1,630 @@
|
||||
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
|
||||
}
|
||||
108
ee/meterreporter/httpmeterreporter/provider_internal_test.go
Normal file
108
ee/meterreporter/httpmeterreporter/provider_internal_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
}
|
||||
90
ee/meterreporter/httpmeterreporter/telemetry.go
Normal file
90
ee/meterreporter/httpmeterreporter/telemetry.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
@@ -150,6 +150,72 @@ 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 {
|
||||
@@ -185,12 +251,21 @@ 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 {
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"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",
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
AuthtypesUpdateableAuthDomainDTO,
|
||||
CreateAuthDomain200,
|
||||
DeleteAuthDomainPathParameters,
|
||||
GetAuthDomain200,
|
||||
GetAuthDomainPathParameters,
|
||||
ListAuthDomains200,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAuthDomainPathParameters,
|
||||
@@ -277,6 +279,109 @@ 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
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigReceiverDTO,
|
||||
CreateChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
@@ -122,14 +123,14 @@ export const invalidateListChannels = async (
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const createChannel = (
|
||||
configReceiverDTO: BodyType<ConfigReceiverDTO>,
|
||||
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateChannel201>({
|
||||
url: `/api/v1/channels`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: configReceiverDTO,
|
||||
data: alertmanagertypesPostableChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -141,13 +142,13 @@ export const getCreateChannelMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createChannel'];
|
||||
@@ -161,7 +162,7 @@ export const getCreateChannelMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
{ data: BodyType<ConfigReceiverDTO> }
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -174,7 +175,8 @@ export const getCreateChannelMutationOptions = <
|
||||
export type CreateChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createChannel>>
|
||||
>;
|
||||
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
|
||||
export type CreateChannelMutationBody =
|
||||
BodyType<AlertmanagertypesPostableChannelDTO>;
|
||||
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -187,13 +189,13 @@ export const useCreateChannel = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createChannel>>,
|
||||
TError,
|
||||
{ data: BodyType<ConfigReceiverDTO> },
|
||||
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateChannelMutationOptions(options);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,10 +47,16 @@ 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,
|
||||
);
|
||||
|
||||
@@ -105,6 +111,12 @@ 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;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 0.3rem;
|
||||
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);
|
||||
transform: translate3d(
|
||||
var(--tanstack-header-translate-x, 0px),
|
||||
var(--tanstack-header-translate-y, 0px),
|
||||
@@ -19,7 +22,17 @@
|
||||
}
|
||||
|
||||
border: none !important;
|
||||
background-color: var(--l2-background) !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;
|
||||
}
|
||||
}
|
||||
|
||||
.tanstackHeaderContent {
|
||||
@@ -61,7 +74,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: grab;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
|
||||
opacity: 1;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -74,7 +87,7 @@
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
|
||||
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -82,8 +95,9 @@
|
||||
.tanstackColumnActionsContent {
|
||||
width: 140px;
|
||||
padding: 0;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
|
||||
border: 1px solid
|
||||
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -137,7 +151,7 @@
|
||||
}
|
||||
|
||||
.tanstackHeaderCell.isResizing .cursorColResize {
|
||||
background: var(--bg-robin-300);
|
||||
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
|
||||
}
|
||||
|
||||
.tanstackResizeHandleLine {
|
||||
@@ -147,7 +161,7 @@
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--l2-background);
|
||||
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
@@ -155,13 +169,34 @@
|
||||
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(--l2-border);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
|
||||
width: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -213,7 +248,12 @@
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&[data-sort-direction='asc'],
|
||||
&[data-sort-direction='desc'] {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.isSortable {
|
||||
|
||||
@@ -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 { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
|
||||
|
||||
import { SortState, TableColumnDef } from './types';
|
||||
|
||||
@@ -177,12 +177,17 @@ function TanStackHeaderRow<TData>({
|
||||
? column.header()
|
||||
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className={headerStyles.tanstackSortIndicator}>
|
||||
<span
|
||||
className={headerStyles.tanstackSortIndicator}
|
||||
data-sort-direction={currentSortDirection || 'none'}
|
||||
>
|
||||
{currentSortDirection === 'asc' ? (
|
||||
<ChevronUp size={SORT_ICON_SIZE} />
|
||||
<ArrowUp size={SORT_ICON_SIZE} />
|
||||
) : currentSortDirection === 'desc' ? (
|
||||
<ChevronDown size={SORT_ICON_SIZE} />
|
||||
) : null}
|
||||
<ArrowDown size={SORT_ICON_SIZE} />
|
||||
) : (
|
||||
<ArrowUpDown size={SORT_ICON_SIZE} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
.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;
|
||||
@@ -26,7 +44,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(--l2-foreground);
|
||||
color: var(--tanstack-table-cell-color, var(--l2-foreground));
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -42,13 +60,35 @@
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: 0.3rem;
|
||||
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);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
font-size: var(--tanstack-plain-cell-font-size, 14px);
|
||||
line-height: var(--tanstack-plain-cell-line-height, 18px);
|
||||
color: var(--l2-foreground);
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
@@ -58,19 +98,69 @@
|
||||
|
||||
&:hover {
|
||||
.tableCell {
|
||||
background-color: var(--row-hover-bg) !important;
|
||||
background-color: var(
|
||||
--tanstack-table-row-hover-bg,
|
||||
var(--row-hover-bg)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.tableRowActive {
|
||||
.tableCell {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
background-color: var(
|
||||
--tanstack-table-row-active-bg,
|
||||
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: 0.3rem;
|
||||
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
|
||||
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
|
||||
height: 36px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
@@ -78,20 +168,40 @@
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
// TODO: Remove this once background color (l1) is matching the actual background color of the page
|
||||
&[data-dark-mode='true'] {
|
||||
background: #0b0c0d;
|
||||
}
|
||||
|
||||
&[data-dark-mode='false'] {
|
||||
background: #fdfdfd;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -90,6 +90,7 @@ function TanStackTableInner<TData>(
|
||||
skeletonRowCount = 10,
|
||||
enableQueryParams,
|
||||
pagination,
|
||||
paginationClassname,
|
||||
onEndReached,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
@@ -112,9 +113,17 @@ 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();
|
||||
|
||||
@@ -221,6 +230,15 @@ 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,
|
||||
@@ -229,7 +247,7 @@ function TanStackTableInner<TData>(
|
||||
columnResizeMode: 'onChange',
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId,
|
||||
enableExpanding: Boolean(renderExpandedRow),
|
||||
enableExpanding: isExpandEnabled,
|
||||
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
|
||||
onColumnSizingChange: handleColumnSizingChange,
|
||||
onColumnVisibilityChange: noopColumnVisibility,
|
||||
@@ -333,6 +351,7 @@ function TanStackTableInner<TData>(
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
enableAlternatingRowColors,
|
||||
}),
|
||||
[
|
||||
getRowStyle,
|
||||
@@ -350,6 +369,7 @@ function TanStackTableInner<TData>(
|
||||
hasSingleColumn,
|
||||
columnOrderKey,
|
||||
columnVisibilityKey,
|
||||
enableAlternatingRowColors,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -500,7 +520,10 @@ function TanStackTableInner<TData>(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
|
||||
<div
|
||||
className={cx(viewStyles.tanstackTableViewWrapper, className)}
|
||||
data-has-group-by={(groupBy?.length || 0) > 0}
|
||||
>
|
||||
<TanStackTableStateProvider>
|
||||
<TableLoadingSync
|
||||
isLoading={isLoading}
|
||||
@@ -508,23 +531,53 @@ function TanStackTableInner<TData>(
|
||||
/>
|
||||
<ColumnVisibilitySync visibility={effectiveVisibility} />
|
||||
<TooltipProvider>
|
||||
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
|
||||
className={virtuosoClassName}
|
||||
ref={virtuosoRef}
|
||||
{...restTableScrollerProps}
|
||||
data={flatItems}
|
||||
totalCount={flatItems.length}
|
||||
context={virtuosoContext}
|
||||
increaseViewportBy={INCREASE_VIEWPORT_BY}
|
||||
initialTopMostItemIndex={
|
||||
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
|
||||
}
|
||||
fixedHeaderContent={tableHeader}
|
||||
style={virtuosoTableStyle}
|
||||
components={virtuosoComponents}
|
||||
endReached={onEndReached ? handleEndReached : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{showInfiniteScrollLoader && (
|
||||
<div
|
||||
className={viewStyles.tanstackLoadingOverlay}
|
||||
@@ -534,7 +587,7 @@ function TanStackTableInner<TData>(
|
||||
</div>
|
||||
)}
|
||||
{showPagination && pagination && (
|
||||
<div className={viewStyles.paginationContainer}>
|
||||
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
|
||||
{prefixPaginationContent}
|
||||
<Pagination
|
||||
current={page}
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bg-slate-300) transparent;
|
||||
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-slate-300))
|
||||
transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
@@ -65,12 +66,12 @@
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
background: var(--tanstack-table-scrollbar-color, var(--bg-slate-300));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
background: var(--tanstack-table-scrollbar-hover-color, var(--bg-slate-200));
|
||||
}
|
||||
|
||||
&.cellTypographySmall {
|
||||
@@ -135,18 +136,25 @@
|
||||
z-index: 3;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
:global(.lightMode) .tanstackTableVirtuosoScroll {
|
||||
scrollbar-color: var(--bg-vanilla-300) transparent;
|
||||
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300))
|
||||
transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
background: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300));
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-100);
|
||||
background: var(
|
||||
--tanstack-table-scrollbar-hover-color,
|
||||
var(--bg-vanilla-100)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +389,101 @@ 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', () => {
|
||||
|
||||
@@ -138,7 +138,10 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
|
||||
|
||||
it('reads initial page from URL params', () => {
|
||||
const wrapper = createNuqsWrapper({ page: '3' });
|
||||
const { result } = renderHook(() => useTableParams(true), { wrapper });
|
||||
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
|
||||
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
@@ -249,3 +252,294 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,27 @@ 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,
|
||||
|
||||
@@ -107,6 +107,8 @@ 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 = {
|
||||
@@ -116,10 +118,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> = {
|
||||
@@ -137,6 +139,7 @@ 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. */
|
||||
@@ -176,6 +179,10 @@ 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 & {
|
||||
|
||||
@@ -8,6 +8,12 @@ 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;
|
||||
@@ -49,30 +55,49 @@ 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}_page`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.page
|
||||
: 'page';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
|
||||
: URL_KEYS_DEFAULT.page;
|
||||
const limitQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_limit`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.limit
|
||||
: 'limit';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
|
||||
: URL_KEYS_DEFAULT.limit;
|
||||
const orderByQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_order_by`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.orderBy
|
||||
: 'order_by';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
|
||||
: URL_KEYS_DEFAULT.orderBy;
|
||||
const expandedQueryParam =
|
||||
typeof enableQueryParams === 'string'
|
||||
? `${enableQueryParams}_expanded`
|
||||
: typeof enableQueryParams === 'object'
|
||||
? enableQueryParams.expanded
|
||||
: 'expanded';
|
||||
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
|
||||
: isObjectConfig
|
||||
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
|
||||
: URL_KEYS_DEFAULT.expanded;
|
||||
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
|
||||
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
|
||||
const orderByDefault = defaults?.orderBy ?? null;
|
||||
@@ -149,45 +174,29 @@ 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 (isEnabledQueryParams) {
|
||||
if (useUrlForPage) {
|
||||
setUrlPage(pageDefault);
|
||||
} else {
|
||||
setLocalPage(pageDefault);
|
||||
}
|
||||
}, [
|
||||
isEnabledQueryParams,
|
||||
useUrlForPage,
|
||||
orderByDefaultMemoKey,
|
||||
orderByUrlMemoKey,
|
||||
pageDefault,
|
||||
setUrlPage,
|
||||
]);
|
||||
|
||||
if (enableQueryParams) {
|
||||
return {
|
||||
page: urlPage,
|
||||
limit: urlLimit,
|
||||
orderBy: urlOrderBy as SortState | null,
|
||||
expanded: urlExpanded,
|
||||
setPage: setUrlPage,
|
||||
setLimit: setUrlLimit,
|
||||
setOrderBy: setUrlOrderBy,
|
||||
setExpanded: setUrlExpanded,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
page: localPage,
|
||||
limit: localLimit,
|
||||
orderBy: localOrderBy,
|
||||
expanded: localExpanded,
|
||||
setPage: setLocalPage,
|
||||
setLimit: setLocalLimit,
|
||||
setOrderBy: setLocalOrderBy,
|
||||
setExpanded: handleSetLocalExpanded,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ export const getColumnWidthStyle = <TData>(
|
||||
): CSSProperties => {
|
||||
// Last column always fills remaining space
|
||||
if (isLastColumn) {
|
||||
return { width: '100%' };
|
||||
return {
|
||||
width: '100%',
|
||||
minWidth: persistedWidth ?? column?.width?.min,
|
||||
};
|
||||
}
|
||||
|
||||
const { width } = column;
|
||||
@@ -59,10 +62,19 @@ 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);
|
||||
}
|
||||
|
||||
@@ -38,5 +38,4 @@ 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',
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, 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 {
|
||||
@@ -24,13 +19,6 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
|
||||
dashboardData?.id,
|
||||
);
|
||||
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(dashboardData?.id);
|
||||
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const {
|
||||
@@ -112,8 +100,8 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<div className="overview-content">
|
||||
<Col className="overview-settings">
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
@@ -124,29 +112,27 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Dashboard Name
|
||||
</Typography>
|
||||
<section className="name-icon-input">
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
rootClassName="dashboard-image-input"
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
<img src={icon} alt="dashboard-icon" className="list-item-image" />
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
@@ -154,88 +140,41 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Description
|
||||
</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
className="description-text-area"
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
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={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
<div className="overview-settings-footer">
|
||||
<div className="unsaved">
|
||||
<div className="unsaved-dot" />
|
||||
<Typography.Text className="unsaved-changes">
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<div className="footer-action-btns">
|
||||
<Button
|
||||
disabled={updateDashboardMutation.isLoading}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
className="discard-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -249,7 +188,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
className="save-btn"
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function ChartWrapper({
|
||||
onClick,
|
||||
syncMode,
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
@@ -71,9 +70,8 @@ export default function ChartWrapper({
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
filterMode: syncFilterMode,
|
||||
}),
|
||||
[yAxisUnit, groupBy, syncFilterMode],
|
||||
[yAxisUnit, groupBy],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -31,7 +30,6 @@ interface UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
onDestroy?: (plot: uPlot) => void;
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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';
|
||||
@@ -37,10 +34,6 @@ 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);
|
||||
|
||||
@@ -82,11 +75,6 @@ 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(() => {
|
||||
@@ -134,7 +122,6 @@ 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,
|
||||
@@ -151,8 +138,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -3,13 +3,10 @@ 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';
|
||||
@@ -36,10 +33,6 @@ 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);
|
||||
|
||||
@@ -88,11 +81,6 @@ 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(() => {
|
||||
@@ -125,7 +113,6 @@ 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,
|
||||
@@ -138,8 +125,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
groupBy={groupBy}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
<ContextMenu
|
||||
|
||||
@@ -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 {
|
||||
useInfraMonitoringCurrentPage,
|
||||
useInfraMonitoringFilters,
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringPageListing,
|
||||
} 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 {
|
||||
hostColumns,
|
||||
getHostItemKey,
|
||||
getHostRowKey,
|
||||
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] = useInfraMonitoringCurrentPage();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
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}
|
||||
renderRowData={hostRenderRowData}
|
||||
getRowKey={getHostRowKey}
|
||||
getItemKey={getHostItemKey}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +1,22 @@
|
||||
import React from 'react';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import { HostData } from 'api/infraMonitoring/getHostLists';
|
||||
import { K8sRenderedRowData } from 'container/InfraMonitoringK8s/Base/types';
|
||||
import { IEntityColumn } from 'container/InfraMonitoringK8s/Base/useInfraMonitoringTableColumnsStore';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { getGroupByEl } from 'container/InfraMonitoringK8s/Base/utils';
|
||||
import {
|
||||
getGroupByEl,
|
||||
getGroupedByMeta,
|
||||
getRowKey,
|
||||
} from 'container/InfraMonitoringK8s/Base/utils';
|
||||
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8s/commonUtils';
|
||||
EntityProgressBar,
|
||||
ExpandButtonWrapper,
|
||||
ValidateColumnValueWrapper,
|
||||
} from 'container/InfraMonitoringK8s/components';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8s/hooks';
|
||||
import EntityGroupHeader from 'container/InfraMonitoringK8s/Base/EntityGroupHeader';
|
||||
|
||||
import { getMemoryProgressColor, getProgressColor } from './constants';
|
||||
import { HostnameCell } from './utils';
|
||||
|
||||
import styles from './table.module.scss';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
import { Container } from 'lucide-react';
|
||||
|
||||
function hostRowSource(host: HostData): { meta: Record<string, string> } {
|
||||
return {
|
||||
@@ -168,67 +30,154 @@ function hostRowSource(host: HostData): { meta: Record<string, string> } {
|
||||
};
|
||||
}
|
||||
|
||||
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 getHostRowKey(host: HostData): string {
|
||||
return host.hostName || 'unknown';
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
),
|
||||
cpu: (
|
||||
<div className={styles.progressContainer}>
|
||||
<ValidateColumnValueWrapper
|
||||
value={host.cpu}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
>
|
||||
<Progress
|
||||
percent={cpuPercent}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={getProgressColor(cpuPercent)}
|
||||
className={styles.progressBar}
|
||||
/>
|
||||
</ValidateColumnValueWrapper>
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
memory: (
|
||||
<div className={styles.progressContainer}>
|
||||
<ValidateColumnValueWrapper
|
||||
value={host.memory}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
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
|
||||
}`}
|
||||
>
|
||||
<Progress
|
||||
percent={memoryPercent}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={getMemoryProgressColor(memoryPercent)}
|
||||
className={styles.progressBar}
|
||||
/>
|
||||
</ValidateColumnValueWrapper>
|
||||
{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>
|
||||
</div>
|
||||
),
|
||||
wait: `${Number((host.wait * 100).toFixed(1))}%`,
|
||||
load15: host.load15,
|
||||
};
|
||||
};
|
||||
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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -190,16 +187,19 @@ 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(() => {
|
||||
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 { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
),
|
||||
[queryKeyPrefix, selectedItem, selectedTime],
|
||||
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,175 +1,40 @@
|
||||
.clickableRow {
|
||||
cursor: pointer;
|
||||
.emptyStateContainer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.k8SListTable {
|
||||
--k8s-base-list-pagination-offset: 64px;
|
||||
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;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
:global(.ant-spin-nested-loading) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
--tanstack-expansion-first-col-padding-left: 30px;
|
||||
|
||||
: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%);
|
||||
}
|
||||
}
|
||||
&[data-has-group-by='false'] {
|
||||
--tanstack-cell-padding-left-first-column: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.paginationContainer {
|
||||
padding-bottom: var(--spacing-8);
|
||||
padding-right: var(--spacing-8);
|
||||
|
||||
: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;
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,36 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// 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 { Typography } from 'antd';
|
||||
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 {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
useInfraMonitoringCurrentPage,
|
||||
useInfraMonitoringFilters,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringPageSizeListing,
|
||||
} from '../hooks';
|
||||
import { usePageSize } from '../utils';
|
||||
import { K8sEmptyState } from './K8sEmptyState';
|
||||
import { K8sExpandedRow } from './K8sExpandedRow';
|
||||
import K8sHeader from './K8sHeader';
|
||||
import { K8sBaseFilters, K8sRenderedRowData } from './types';
|
||||
import {
|
||||
IEntityColumn,
|
||||
useInfraMonitoringTableColumnsForPage,
|
||||
useInfraMonitoringTableColumnsStore,
|
||||
} from './useInfraMonitoringTableColumnsStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
|
||||
import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
export type K8sBaseListEmptyStateContext = {
|
||||
isError: boolean;
|
||||
@@ -53,11 +41,13 @@ export type K8sBaseListEmptyStateContext = {
|
||||
rawData?: unknown;
|
||||
};
|
||||
|
||||
export type K8sBaseListProps<T = unknown> = {
|
||||
/** Base type constraint for K8s entity data */
|
||||
export type K8sEntityData = { meta?: Record<string, string> };
|
||||
|
||||
export type K8sBaseListProps<T extends K8sEntityData> = {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
entity: InfraMonitoringEntity;
|
||||
tableColumnsDefinitions: IEntityColumn[];
|
||||
tableColumns: ColumnType<K8sRenderedRowData>[];
|
||||
tableColumns: TableColumnDef<T>[];
|
||||
fetchListData: (
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
@@ -67,69 +57,63 @@ export type K8sBaseListProps<T = unknown> = {
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
}>;
|
||||
renderRowData: (
|
||||
record: T,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
) => K8sRenderedRowData;
|
||||
/** 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;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
renderEmptyState?: (
|
||||
context: K8sBaseListEmptyStateContext,
|
||||
) => React.ReactNode | null;
|
||||
};
|
||||
|
||||
export function K8sBaseList<T>({
|
||||
export function K8sBaseList<T extends K8sEntityData>({
|
||||
controlListPrefix,
|
||||
entity,
|
||||
tableColumnsDefinitions,
|
||||
tableColumns,
|
||||
fetchListData,
|
||||
renderRowData,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
eventCategory,
|
||||
renderEmptyState,
|
||||
}: K8sBaseListProps<T>): JSX.Element {
|
||||
const [queryFilters] = useInfraMonitoringFilters();
|
||||
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
|
||||
const [queryFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [currentPage] = useInfraMonitoringPageListing();
|
||||
const [currentPageSize] = useInfraMonitoringPageSizeListing();
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [initialOrderBy] = useState(orderBy);
|
||||
const [orderBy] = useInfraMonitoringOrderBy();
|
||||
const [selectedItem, setSelectedItem] = useQueryState(
|
||||
'selectedItem',
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
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 columnStorageKey = `k8s-${entity}-columns`;
|
||||
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
|
||||
|
||||
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(pageSize),
|
||||
String(currentPageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
pageSize,
|
||||
currentPageSize,
|
||||
currentPage,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
@@ -143,8 +127,8 @@ export function K8sBaseList<T>({
|
||||
|
||||
return fetchListData(
|
||||
{
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
limit: currentPageSize,
|
||||
offset: (currentPage - 1) * currentPageSize,
|
||||
filters: queryFilters || { items: [], op: 'AND' },
|
||||
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
@@ -157,62 +141,14 @@ export function K8sBaseList<T>({
|
||||
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 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],
|
||||
);
|
||||
const getGroupKeyFn = useCallback(
|
||||
(item: T) => getGroupedByMeta(item, groupBy),
|
||||
[groupBy],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
@@ -223,214 +159,137 @@ export function K8sBaseList<T>({
|
||||
});
|
||||
}, [eventCategory, totalCount]);
|
||||
|
||||
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
|
||||
if (expandedRowKeys.includes(record.key)) {
|
||||
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
|
||||
} else {
|
||||
setExpandedRowKeys([record.key]);
|
||||
}
|
||||
};
|
||||
|
||||
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 mapDefaultSort = useCallback(
|
||||
(
|
||||
tableColumn: ColumnType<K8sRenderedRowData>,
|
||||
): ColumnType<K8sRenderedRowData> => {
|
||||
if (tableColumn.key === initialOrderBy?.columnName) {
|
||||
return {
|
||||
...tableColumn,
|
||||
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
|
||||
};
|
||||
const handleRowClick = useCallback(
|
||||
(_record: T, itemKey: string): void => {
|
||||
if (groupBy.length === 0) {
|
||||
setSelectedItem(itemKey);
|
||||
}
|
||||
|
||||
return tableColumn;
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
},
|
||||
[initialOrderBy?.columnName, initialOrderBy?.order],
|
||||
[eventCategory, groupBy.length, setSelectedItem],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
tableColumns
|
||||
.filter(
|
||||
(c) =>
|
||||
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
|
||||
!columnsHidden.includes(c.key?.toString() || ''),
|
||||
)
|
||||
.map(mapDefaultSort),
|
||||
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(_record: T, itemKey: string): void => {
|
||||
if (groupBy.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
[eventCategory, groupBy.length],
|
||||
);
|
||||
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
|
||||
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
|
||||
<K8sExpandedRow<T>
|
||||
record={record}
|
||||
entity={entity}
|
||||
tableColumns={tableColumns}
|
||||
fetchListData={fetchListData}
|
||||
renderRowData={renderRowData}
|
||||
/>
|
||||
// Filter columns for expanded row based on parent's hidden columns
|
||||
const expandedRowColumns = useMemo(
|
||||
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
|
||||
[tableColumns, hiddenColumnIds],
|
||||
);
|
||||
|
||||
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 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],
|
||||
);
|
||||
|
||||
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 getRowCanExpand = useCallback(
|
||||
(): boolean => isGroupedByAttribute,
|
||||
[isGroupedByAttribute],
|
||||
);
|
||||
|
||||
const showTableLoadingState = isLoading;
|
||||
|
||||
const emptyStateContext: K8sBaseListEmptyStateContext = {
|
||||
isError: isError || !!data?.error,
|
||||
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
|
||||
isError,
|
||||
error: data?.error,
|
||||
totalCount,
|
||||
hasFilters,
|
||||
isLoading: showTableLoadingState,
|
||||
rawData: data?.rawData,
|
||||
};
|
||||
|
||||
const emptyTableMessage: React.ReactNode = renderEmptyState?.(
|
||||
emptyStateContext,
|
||||
) || (
|
||||
}) || (
|
||||
<K8sEmptyState
|
||||
isError={emptyStateContext.isError}
|
||||
error={emptyStateContext.error}
|
||||
isLoading={emptyStateContext.isLoading}
|
||||
rawData={emptyStateContext.rawData}
|
||||
isError={isError}
|
||||
error={data?.error}
|
||||
isLoading={showTableLoadingState}
|
||||
rawData={data?.rawData}
|
||||
/>
|
||||
);
|
||||
|
||||
const showEmptyState = !showTableLoadingState && pageData.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sHeader
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={entity}
|
||||
showAutoRefresh={!selectedItem}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={columnStorageKey}
|
||||
/>
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
.title {
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -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 { K8sBaseListEmptyStateContext } from './K8sBaseList';
|
||||
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
|
||||
|
||||
import styles from './K8sEmptyState.module.scss';
|
||||
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
.expandedClickableRow {
|
||||
cursor: pointer;
|
||||
.expandedTableContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.expandedTableContainer {
|
||||
border: 1px solid var(--l1-border);
|
||||
overflow-x: auto;
|
||||
padding-left: 48px;
|
||||
.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;
|
||||
|
||||
:global(.ant-table-tbody > tr:hover > td) {
|
||||
background: none !important;
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
& [data-hide-expanded='true'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedTableFooter {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
padding-left: 42px;
|
||||
margin-top: 8px;
|
||||
gap: var(--spacing-4);
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
.viewAllButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Spin,
|
||||
Table,
|
||||
TableColumnType as ColumnType,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Typography } from 'antd';
|
||||
import TanStackTable, {
|
||||
SortState,
|
||||
TableColumnDef,
|
||||
TanStackTableStateProvider,
|
||||
} from 'components/TanStackTableView';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
useInfraMonitoringCurrentPage,
|
||||
useInfraMonitoringFilters,
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItem,
|
||||
} from '../hooks';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
import { K8sBaseFilters, K8sRenderedRowData } from './types';
|
||||
import { useInfraMonitoringTableColumnsForPage } from './useInfraMonitoringTableColumnsStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
|
||||
import styles from './K8sExpandedRow.module.scss';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
export type K8sExpandedRowProps<T> = {
|
||||
record: K8sRenderedRowData;
|
||||
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
|
||||
rowKey: string;
|
||||
/** Group metadata for building filters */
|
||||
groupMeta?: Record<string, string>;
|
||||
entity: InfraMonitoringEntity;
|
||||
tableColumns: ColumnType<K8sRenderedRowData>[];
|
||||
tableColumns: TableColumnDef<T>[];
|
||||
fetchListData: (
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
@@ -47,47 +44,46 @@ export type K8sExpandedRowProps<T> = {
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
}>;
|
||||
renderRowData: (
|
||||
record: T,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
) => K8sRenderedRowData;
|
||||
/** 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;
|
||||
};
|
||||
|
||||
export const MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY = 10;
|
||||
|
||||
export function K8sExpandedRow<T>({
|
||||
record,
|
||||
rowKey,
|
||||
groupMeta,
|
||||
entity,
|
||||
tableColumns,
|
||||
fetchListData,
|
||||
renderRowData,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
}: K8sExpandedRowProps<T>): JSX.Element {
|
||||
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
|
||||
const [queryFilters, setFilters] = useInfraMonitoringFilters();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
|
||||
|
||||
const [columnsDefinitions, columnsHidden] =
|
||||
useInfraMonitoringTableColumnsForPage(entity);
|
||||
|
||||
const hiddenColumnIdsForNested = useMemo(
|
||||
() =>
|
||||
columnsDefinitions
|
||||
.filter((col) => col.behavior === 'hidden-on-collapse')
|
||||
.map((col) => col.id),
|
||||
[columnsDefinitions],
|
||||
const orderByParamKey = useMemo(
|
||||
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
|
||||
[rowKey],
|
||||
);
|
||||
|
||||
const nestedColumns = useMemo(
|
||||
() =>
|
||||
tableColumns.filter(
|
||||
(c) =>
|
||||
!columnsHidden.includes(c.key?.toString() || '') &&
|
||||
!hiddenColumnIdsForNested.includes(c.key?.toString() || ''),
|
||||
),
|
||||
[tableColumns, columnsHidden, hiddenColumnIdsForNested],
|
||||
const [orderBy, setOrderBy] = useQueryState(
|
||||
orderByParamKey,
|
||||
parseAsJsonNoValidate<SortState | null>()
|
||||
.withDefault(null as never)
|
||||
.withOptions({
|
||||
history: 'push',
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
void setOrderBy(null);
|
||||
};
|
||||
}, [setOrderBy]);
|
||||
|
||||
const storageKey = `k8s-${entity}-columns-expanded`;
|
||||
|
||||
const createFiltersForRecord = useCallback((): NonNullable<
|
||||
IBuilderQuery['filters']
|
||||
@@ -97,45 +93,64 @@ export function K8sExpandedRow<T>({
|
||||
op: 'and',
|
||||
};
|
||||
|
||||
const { groupedByMeta } = record;
|
||||
const metaKeys = groupMeta ?? {};
|
||||
|
||||
for (const key of Object.keys(groupedByMeta)) {
|
||||
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;
|
||||
}
|
||||
baseFilters.items.push({
|
||||
key: {
|
||||
key,
|
||||
type: null,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: groupedByMeta[key],
|
||||
value,
|
||||
id: key,
|
||||
});
|
||||
}
|
||||
|
||||
return baseFilters;
|
||||
}, [queryFilters?.items, record]);
|
||||
}, [queryFilters?.items, groupMeta]);
|
||||
|
||||
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, [
|
||||
return getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
entity,
|
||||
'k8sExpandedRow',
|
||||
record.key,
|
||||
JSON.stringify(groupMeta),
|
||||
rowKey,
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
]);
|
||||
}, [selectedTime, record.key, queryFilters, orderBy]);
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
groupMeta,
|
||||
rowKey,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => {
|
||||
queryFn: async ({ signal }) => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
return fetchListData(
|
||||
return await fetchListData(
|
||||
{
|
||||
limit: MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY,
|
||||
limit: EXPANDED_ROW_LIMIT,
|
||||
offset: 0,
|
||||
filters: createFiltersForRecord(),
|
||||
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
@@ -146,48 +161,45 @@ export function K8sExpandedRow<T>({
|
||||
signal,
|
||||
);
|
||||
},
|
||||
staleTime: 1000 * 60 * 30,
|
||||
refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
});
|
||||
|
||||
const formattedData = useMemo(() => {
|
||||
if (!data?.data) {
|
||||
return undefined;
|
||||
}
|
||||
const expandedData = data?.data ?? [];
|
||||
|
||||
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 handleRowClick = useCallback(
|
||||
(_row: T, itemKey: string): void => {
|
||||
setSelectedItem(itemKey);
|
||||
},
|
||||
[setSelectedItem],
|
||||
);
|
||||
|
||||
const handleViewAllClick = (): void => {
|
||||
const filters = createFiltersForRecord();
|
||||
setFilters(filters);
|
||||
setCurrentPage(1);
|
||||
setGroupBy([]);
|
||||
setOrderBy(null);
|
||||
setCurrentPage(1);
|
||||
setFilters(filters);
|
||||
if (orderBy) {
|
||||
setMainOrderBy(orderBy);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
@@ -197,50 +209,30 @@ export function K8sExpandedRow<T>({
|
||||
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
|
||||
)}
|
||||
|
||||
{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 />} />,
|
||||
<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,
|
||||
}}
|
||||
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,
|
||||
})}
|
||||
tableScrollerProps={{
|
||||
className: styles.expandedTable,
|
||||
}}
|
||||
disableVirtualScroll
|
||||
cellTypographySize="medium"
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</TanStackTableStateProvider>
|
||||
{!isLoading && expandedData.length > 0 && (
|
||||
<div className={styles.expandedTableFooter}>{footerContent}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, DrawerWrapper } from '@signozhq/ui';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
useInfraMonitoringTableColumnsForPage,
|
||||
useInfraMonitoringTableColumnsStore,
|
||||
} from './useInfraMonitoringTableColumnsStore';
|
||||
hideColumn,
|
||||
showColumn,
|
||||
TableColumnDef,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
|
||||
import styles from './K8sFiltersSidePanel.module.scss';
|
||||
|
||||
function K8sFiltersSidePanel({
|
||||
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>({
|
||||
open,
|
||||
onClose,
|
||||
entity,
|
||||
columns,
|
||||
storageKey,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
entity: InfraMonitoringEntity;
|
||||
columns: TableColumnDef<TData>[];
|
||||
storageKey: string;
|
||||
}): JSX.Element {
|
||||
const addColumn = useInfraMonitoringTableColumnsStore(
|
||||
(state) => state.addColumn,
|
||||
const columnPickerItems = useMemo(
|
||||
() => toColumnPickerItems(columns),
|
||||
[columns],
|
||||
);
|
||||
const removeColumn = useInfraMonitoringTableColumnsStore(
|
||||
(state) => state.removeColumn,
|
||||
const hiddenColumnIds = useHiddenColumnIds(storageKey);
|
||||
|
||||
const addedColumns = useMemo(
|
||||
() =>
|
||||
columnPickerItems.filter(
|
||||
(column) =>
|
||||
!hiddenColumnIds.includes(column.id) &&
|
||||
column.visibilityBehavior !== 'hidden-on-collapse',
|
||||
),
|
||||
[columnPickerItems, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const [columns, columnsHidden] = useInfraMonitoringTableColumnsForPage(entity);
|
||||
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 drawerContent = (
|
||||
<>
|
||||
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
|
||||
|
||||
<div className={styles.columnsList}>
|
||||
{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>
|
||||
))}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.horizontalDivider} />
|
||||
@@ -59,23 +100,21 @@ function K8sFiltersSidePanel({
|
||||
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
|
||||
|
||||
<div className={styles.columnsList}>
|
||||
{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>
|
||||
))}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -19,27 +20,31 @@ import {
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringCurrentPage,
|
||||
useInfraMonitoringFilters,
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringPageListing,
|
||||
} from '../hooks';
|
||||
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
|
||||
|
||||
import styles from './K8sHeader.module.scss';
|
||||
|
||||
interface K8sHeaderProps {
|
||||
interface K8sHeaderProps<TData> {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
entity: InfraMonitoringEntity;
|
||||
showAutoRefresh: boolean;
|
||||
columns: TableColumnDef<TData>[];
|
||||
columnStorageKey: string;
|
||||
}
|
||||
|
||||
function K8sHeader({
|
||||
function K8sHeader<TData>({
|
||||
controlListPrefix,
|
||||
entity,
|
||||
showAutoRefresh,
|
||||
}: K8sHeaderProps): JSX.Element {
|
||||
columns,
|
||||
columnStorageKey,
|
||||
}: K8sHeaderProps<TData>): JSX.Element {
|
||||
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
const currentQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
@@ -77,7 +82,7 @@ function K8sHeader({
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const handleChangeTagFilters = useCallback(
|
||||
(value: IBuilderQuery['filters']) => {
|
||||
setUrlFilters(value || null);
|
||||
@@ -207,7 +212,6 @@ function K8sHeader({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="none"
|
||||
disabled={groupBy?.length > 0}
|
||||
data-testid="k8s-list-filters-button"
|
||||
onClick={(): void => setIsFiltersSidePanelOpen(true)}
|
||||
>
|
||||
@@ -217,7 +221,8 @@ function K8sHeader({
|
||||
|
||||
<K8sFiltersSidePanel
|
||||
open={isFiltersSidePanelOpen}
|
||||
entity={entity}
|
||||
columns={columns}
|
||||
storageKey={columnStorageKey}
|
||||
onClose={onClickOutside}
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,15 +13,14 @@ export type K8sBaseFilters = {
|
||||
orderBy?: OrderBySchemaType;
|
||||
};
|
||||
|
||||
export type K8sRenderedRowData = {
|
||||
/**
|
||||
* The unique ID for the row
|
||||
*/
|
||||
/**
|
||||
* Type for table row data with required key fields.
|
||||
* Used when rendering raw data in the table.
|
||||
*/
|
||||
export type K8sTableRowData<T> = T & {
|
||||
key: string;
|
||||
/**
|
||||
* The ID to the selectedItem
|
||||
*/
|
||||
id: string;
|
||||
itemKey: string;
|
||||
groupedByMeta: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
/** Metadata about which attributes were used for grouping */
|
||||
groupedByMeta?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -22,30 +22,32 @@ 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 itemData.meta;
|
||||
result[rawKey] = (itemData.meta[metaKey] || itemData.meta[rawKey]) ?? '';
|
||||
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
|
||||
result[rawKey] = (meta[metaKey] || 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(itemData.meta);
|
||||
return nodeIdentifier || JSON.stringify(meta);
|
||||
}
|
||||
|
||||
const groupedMeta = getGroupedByMeta(itemData, groupBy);
|
||||
@@ -61,30 +63,32 @@ export function getRowKey<T extends { meta: Record<string, string> }>(
|
||||
return nodeIdentifier;
|
||||
}
|
||||
|
||||
return JSON.stringify(itemData.meta);
|
||||
return JSON.stringify(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 itemData.meta;
|
||||
const value = itemData.meta[metaKey] || itemData.meta[rawKey] || '<no-value>';
|
||||
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
|
||||
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
|
||||
|
||||
groupByValues.push(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.itemDataGroup}>
|
||||
{groupByValues.map((value) => (
|
||||
{groupByValues.map((value, index) => (
|
||||
<Badge
|
||||
key={value}
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
key={`${index}-${value}`}
|
||||
color="secondary"
|
||||
className={styles.itemDataGroupTagItem}
|
||||
>
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
k8sClusterInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sClustersColumns,
|
||||
getK8sClusterItemKey,
|
||||
getK8sClusterRowKey,
|
||||
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}
|
||||
renderRowData={k8sClustersRenderRowData}
|
||||
getRowKey={getK8sClusterRowKey}
|
||||
getItemKey={getK8sClusterItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Cluster}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,77 +1,26 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
|
||||
|
||||
import { K8sRenderedRowData } from '../Base/types';
|
||||
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
|
||||
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
|
||||
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { ValidateColumnValueWrapper } from '../components';
|
||||
import { K8sClusterData, K8sClustersListPayload } from './api';
|
||||
import { Boxes } from 'lucide-react';
|
||||
|
||||
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 getK8sClusterRowKey(cluster: K8sClusterData): string {
|
||||
return (
|
||||
cluster.clusterUID ||
|
||||
cluster.meta.k8s_cluster_uid ||
|
||||
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 function getK8sClusterItemKey(cluster: K8sClusterData): string {
|
||||
return cluster.meta.k8s_cluster_name;
|
||||
}
|
||||
|
||||
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
|
||||
filters: {
|
||||
@@ -81,103 +30,110 @@ export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
|
||||
orderBy: { columnName: 'cpu', order: 'desc' },
|
||||
});
|
||||
|
||||
export const k8sClustersColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
|
||||
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
|
||||
{
|
||||
title: (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
<Group size={14} /> CLUSTER GROUP
|
||||
</div>
|
||||
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} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'clusterGroup',
|
||||
key: 'clusterGroup',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
align: 'left',
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Cluster Name</div>,
|
||||
dataIndex: 'clusterName',
|
||||
key: 'clusterName',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
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>CPU Usage (cores)</div>,
|
||||
dataIndex: 'cpu',
|
||||
key: 'cpu',
|
||||
width: 80,
|
||||
sorter: true,
|
||||
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 Alloc (cores)</div>,
|
||||
dataIndex: 'cpu_allocatable',
|
||||
key: 'cpu_allocatable',
|
||||
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>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',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
k8sDaemonSetInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sDaemonSetsColumns,
|
||||
getK8sDaemonSetItemKey,
|
||||
getK8sDaemonSetRowKey,
|
||||
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}
|
||||
renderRowData={k8sDaemonSetsRenderRowData}
|
||||
getRowKey={getK8sDaemonSetRowKey}
|
||||
getItemKey={getK8sDaemonSetItemKey}
|
||||
eventCategory={InfraMonitoringEvents.DaemonSet}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,297 +1,225 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { K8sDaemonSetsData } from './api';
|
||||
import { Group } from 'lucide-react';
|
||||
|
||||
import styles from './table.module.scss';
|
||||
export function getK8sDaemonSetRowKey(daemonSet: K8sDaemonSetsData): string {
|
||||
return (
|
||||
daemonSet.daemonSetName ||
|
||||
daemonSet.meta.k8s_daemonset_name ||
|
||||
`${daemonSet.meta.k8s_namespace_name}-${daemonSet.meta.k8s_daemonset_name}`
|
||||
);
|
||||
}
|
||||
|
||||
export const k8sDaemonSetsColumns: IEntityColumn[] = [
|
||||
export function getK8sDaemonSetItemKey(daemonSet: K8sDaemonSetsData): string {
|
||||
return daemonSet.meta.k8s_daemonset_name;
|
||||
}
|
||||
|
||||
export const k8sDaemonSetsColumnsConfig: TableColumnDef<K8sDaemonSetsData>[] = [
|
||||
{
|
||||
label: 'DaemonSet Group',
|
||||
value: 'daemonSetGroup',
|
||||
id: 'daemonSetGroup',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-collapse',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'DaemonSet Name',
|
||||
value: 'daemonsetName',
|
||||
id: 'daemonsetName',
|
||||
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_nodes',
|
||||
id: 'available_nodes',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Desired',
|
||||
value: 'desired_nodes',
|
||||
id: 'desired_nodes',
|
||||
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 k8sDaemonSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
|
||||
{
|
||||
title: (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
<Group size={14} /> DAEMONSET GROUP
|
||||
</div>
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="DaemonSet Name"
|
||||
icon={<Group data-hide-expanded="true" size={14} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'daemonSetGroup',
|
||||
key: 'daemonSetGroup',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
align: 'left',
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>DaemonSet Name</div>,
|
||||
dataIndex: 'daemonsetName',
|
||||
key: 'daemonsetName',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Namespace Name</div>,
|
||||
dataIndex: 'namespaceName',
|
||||
key: 'namespaceName',
|
||||
ellipsis: true,
|
||||
width: 80,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Available</div>,
|
||||
dataIndex: 'available_nodes',
|
||||
key: 'available_nodes',
|
||||
width: 50,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Desired</div>,
|
||||
dataIndex: 'desired_nodes',
|
||||
key: 'desired_nodes',
|
||||
width: 50,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Req Usage (%)</div>,
|
||||
dataIndex: 'cpu_request',
|
||||
key: 'cpu_request',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Limit Usage (%)</div>,
|
||||
dataIndex: 'cpu_limit',
|
||||
key: 'cpu_limit',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Usage (cores)</div>,
|
||||
dataIndex: 'cpu',
|
||||
key: 'cpu',
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Mem Req Usage (%)</div>,
|
||||
dataIndex: 'memory_request',
|
||||
key: 'memory_request',
|
||||
width: 170,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
k8sDeploymentInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sDeploymentsColumns,
|
||||
getK8sDeploymentItemKey,
|
||||
getK8sDeploymentRowKey,
|
||||
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}
|
||||
renderRowData={k8sDeploymentsRenderRowData}
|
||||
getRowKey={getK8sDeploymentRowKey}
|
||||
getItemKey={getK8sDeploymentItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Deployment}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,269 +1,230 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { K8sDeploymentsData } from './api';
|
||||
import { Computer } from 'lucide-react';
|
||||
|
||||
import styles from './table.module.scss';
|
||||
export function getK8sDeploymentRowKey(deployment: K8sDeploymentsData): string {
|
||||
return deployment.meta.k8s_deployment_name || deployment.deploymentName;
|
||||
}
|
||||
|
||||
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 = (
|
||||
export function getK8sDeploymentItemKey(
|
||||
deployment: K8sDeploymentsData,
|
||||
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),
|
||||
});
|
||||
): 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
|
||||
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
|
||||
import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringFilters,
|
||||
useInfraMonitoringFiltersK8s,
|
||||
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] = useInfraMonitoringFilters();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
k8sJobInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sJobsColumns,
|
||||
getK8sJobItemKey,
|
||||
getK8sJobRowKey,
|
||||
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}
|
||||
renderRowData={k8sJobsRenderRowData}
|
||||
getRowKey={getK8sJobRowKey}
|
||||
getItemKey={getK8sJobItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Job}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,330 +1,249 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { K8sJobsData } from './api';
|
||||
import { Bolt } from 'lucide-react';
|
||||
|
||||
import styles from './table.module.scss';
|
||||
export function getK8sJobRowKey(job: K8sJobsData): string {
|
||||
return job.jobName || job.meta.k8s_job_name || '';
|
||||
}
|
||||
|
||||
export const k8sJobsColumns: IEntityColumn[] = [
|
||||
export function getK8sJobItemKey(job: K8sJobsData): string {
|
||||
return job.meta.k8s_job_name;
|
||||
}
|
||||
|
||||
export const k8sJobsColumnsConfig: TableColumnDef<K8sJobsData>[] = [
|
||||
{
|
||||
label: 'Job Group',
|
||||
value: 'jobGroup',
|
||||
id: 'jobGroup',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-collapse',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Job Name',
|
||||
value: 'jobName',
|
||||
id: 'jobName',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-expand',
|
||||
},
|
||||
{
|
||||
label: 'Namespace Name',
|
||||
value: 'namespaceName',
|
||||
id: 'namespaceName',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Successful',
|
||||
value: 'successful_pods',
|
||||
id: 'successful_pods',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
value: 'failed_pods',
|
||||
id: 'failed_pods',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Desired Successful',
|
||||
value: 'desired_successful_pods',
|
||||
id: 'desired_successful_pods',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
value: 'active_pods',
|
||||
id: 'active_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 k8sJobsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
|
||||
{
|
||||
title: (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
<Group size={14} /> JOB GROUP
|
||||
</div>
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Job Name"
|
||||
icon={<Bolt data-hide-expanded="true" size={14} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'jobGroup',
|
||||
key: 'jobGroup',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
align: 'left',
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Job Name</div>,
|
||||
dataIndex: 'jobName',
|
||||
key: 'jobName',
|
||||
ellipsis: true,
|
||||
width: 80,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Namespace Name</div>,
|
||||
dataIndex: 'namespaceName',
|
||||
key: 'namespaceName',
|
||||
ellipsis: true,
|
||||
width: 80,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Successful</div>,
|
||||
dataIndex: 'successful_pods',
|
||||
key: 'successful_pods',
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Failed</div>,
|
||||
dataIndex: 'failed_pods',
|
||||
key: 'failed_pods',
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Desired Successful</div>,
|
||||
dataIndex: 'desired_successful_pods',
|
||||
key: 'desired_successful_pods',
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Active</div>,
|
||||
dataIndex: 'active_pods',
|
||||
key: 'active_pods',
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Req Usage (%)</div>,
|
||||
dataIndex: 'cpu_request',
|
||||
key: 'cpu_request',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Limit Usage (%)</div>,
|
||||
dataIndex: 'cpu_limit',
|
||||
key: 'cpu_limit',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Usage (cores)</div>,
|
||||
dataIndex: 'cpu',
|
||||
key: 'cpu',
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Mem Req Usage (%)</div>,
|
||||
dataIndex: 'memory_request',
|
||||
key: 'memory_request',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
namespaceWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sNamespacesColumns,
|
||||
getK8sNamespaceItemKey,
|
||||
getK8sNamespaceRowKey,
|
||||
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}
|
||||
renderRowData={k8sNamespacesRenderRowData}
|
||||
getRowKey={getK8sNamespaceRowKey}
|
||||
getItemKey={getK8sNamespaceItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Namespace}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,68 +1,21 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
|
||||
|
||||
import { K8sRenderedRowData } from '../Base/types';
|
||||
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
|
||||
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
|
||||
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { ValidateColumnValueWrapper } from '../components';
|
||||
import { K8sNamespacesData, K8sNamespacesListPayload } from './api';
|
||||
import { FilePenLine } from 'lucide-react';
|
||||
|
||||
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 getK8sNamespaceRowKey(namespace: K8sNamespacesData): string {
|
||||
return namespace.namespaceName || 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 function getK8sNamespaceItemKey(namespace: K8sNamespacesData): string {
|
||||
return namespace.meta.k8s_namespace_name;
|
||||
}
|
||||
|
||||
export const getK8sNamespacesListQuery = (): K8sNamespacesListPayload => ({
|
||||
filters: {
|
||||
@@ -72,84 +25,90 @@ export const getK8sNamespacesListQuery = (): K8sNamespacesListPayload => ({
|
||||
orderBy: { columnName: 'cpu', order: 'desc' },
|
||||
});
|
||||
|
||||
export const k8sNamespacesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
|
||||
export const k8sNamespacesColumnsConfig: TableColumnDef<K8sNamespacesData>[] = [
|
||||
{
|
||||
title: (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
<Group size={14} /> NAMESPACE GROUP
|
||||
</div>
|
||||
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} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'namespaceGroup',
|
||||
key: 'namespaceGroup',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
align: 'left',
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Namespace Name</div>,
|
||||
dataIndex: 'namespaceName',
|
||||
key: 'namespaceName',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>Cluster Name</div>,
|
||||
dataIndex: 'clusterName',
|
||||
key: 'clusterName',
|
||||
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>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',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
nodeWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sNodesColumns,
|
||||
getK8sNodeItemKey,
|
||||
getK8sNodeRowKey,
|
||||
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}
|
||||
renderRowData={k8sNodesRenderRowData}
|
||||
getRowKey={getK8sNodeRowKey}
|
||||
getItemKey={getK8sNodeItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Node}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,86 +1,22 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
|
||||
|
||||
import { K8sRenderedRowData } from '../Base/types';
|
||||
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
|
||||
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
|
||||
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { ValidateColumnValueWrapper } from '../components';
|
||||
import { K8sNodeData, K8sNodesListPayload } from './api';
|
||||
import { Workflow } from 'lucide-react';
|
||||
|
||||
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 getK8sNodeRowKey(node: K8sNodeData): string {
|
||||
return node.nodeUID || node.meta.k8s_node_uid || 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 function getK8sNodeItemKey(node: K8sNodeData): string {
|
||||
return node.meta.k8s_node_name;
|
||||
}
|
||||
|
||||
export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
|
||||
filters: {
|
||||
@@ -90,110 +26,120 @@ export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
|
||||
orderBy: { columnName: 'cpu', order: 'desc' },
|
||||
});
|
||||
|
||||
export const k8sNodesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
|
||||
export const k8sNodesColumnsConfig: TableColumnDef<K8sNodeData>[] = [
|
||||
{
|
||||
title: (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
<Group size={14} /> NODE GROUP
|
||||
</div>
|
||||
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} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'nodeGroup',
|
||||
key: 'nodeGroup',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
align: 'left',
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Node Name</div>,
|
||||
dataIndex: 'nodeName',
|
||||
key: 'nodeName',
|
||||
ellipsis: true,
|
||||
width: 80,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>Cluster Name</div>,
|
||||
dataIndex: 'clusterName',
|
||||
key: 'clusterName',
|
||||
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>CPU Usage (cores)</div>,
|
||||
dataIndex: 'cpu',
|
||||
key: 'cpu',
|
||||
width: 80,
|
||||
sorter: true,
|
||||
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 Alloc (cores)</div>,
|
||||
dataIndex: 'cpu_allocatable',
|
||||
key: 'cpu_allocatable',
|
||||
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>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',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
podWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sPodColumns,
|
||||
getK8sPodItemKey,
|
||||
getK8sPodRowKey,
|
||||
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}
|
||||
renderRowData={k8sPodRenderRowData}
|
||||
getRowKey={getK8sPodRowKey}
|
||||
getItemKey={getK8sPodItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Pod}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,328 +1,207 @@
|
||||
import React from 'react';
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { K8sPodsData } from './api';
|
||||
import { Container } from 'lucide-react';
|
||||
|
||||
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 getK8sPodRowKey(pod: K8sPodsData): string {
|
||||
return pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name;
|
||||
}
|
||||
|
||||
export const k8sPodColumns: IEntityColumn[] = [
|
||||
export function getK8sPodItemKey(pod: K8sPodsData): string {
|
||||
return pod.podUID;
|
||||
}
|
||||
|
||||
export const k8sPodColumnsConfig: TableColumnDef<K8sPodsData>[] = [
|
||||
{
|
||||
label: 'Pod Group',
|
||||
value: 'podGroup',
|
||||
id: 'podGroup',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-collapse',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Pod name',
|
||||
value: 'podName',
|
||||
id: 'podName',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-expand',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
label: 'Namespace name',
|
||||
value: 'namespace',
|
||||
id: 'namespace',
|
||||
canBeHidden: true,
|
||||
defaultVisibility: false,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Node name',
|
||||
value: 'node',
|
||||
id: 'node',
|
||||
canBeHidden: true,
|
||||
defaultVisibility: false,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Cluster name',
|
||||
value: 'cluster',
|
||||
id: 'cluster',
|
||||
canBeHidden: true,
|
||||
defaultVisibility: false,
|
||||
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>
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Pod Name"
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'podGroup',
|
||||
key: 'podGroup',
|
||||
ellipsis: true,
|
||||
width: 180,
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Pod Name</div>,
|
||||
dataIndex: 'podName',
|
||||
key: 'podName',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Req Usage (%)</div>,
|
||||
dataIndex: 'cpu_request',
|
||||
key: 'cpu_request',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Limit Usage (%)</div>,
|
||||
dataIndex: 'cpu_limit',
|
||||
key: 'cpu_limit',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>CPU Usage (cores)</div>,
|
||||
dataIndex: 'cpu',
|
||||
key: 'cpu',
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Mem Req Usage (%)</div>,
|
||||
dataIndex: 'memory_request',
|
||||
key: 'memory_request',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Mem Limit Usage (%)</div>,
|
||||
dataIndex: 'memory_limit',
|
||||
key: 'memory_limit',
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Mem Usage (WSS)</div>,
|
||||
dataIndex: 'memory',
|
||||
key: 'memory',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
id: 'namespace',
|
||||
header: 'Namespace',
|
||||
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <div>Namespace</div>,
|
||||
dataIndex: 'namespace',
|
||||
key: 'namespace',
|
||||
width: 100,
|
||||
sorter: false,
|
||||
ellipsis: true,
|
||||
align: 'left',
|
||||
id: 'node',
|
||||
header: 'Node',
|
||||
accessorFn: (row): string => row.meta.k8s_node_name || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <div>Node</div>,
|
||||
dataIndex: 'node',
|
||||
key: 'node',
|
||||
width: 100,
|
||||
sorter: false,
|
||||
ellipsis: true,
|
||||
align: 'left',
|
||||
id: 'cluster',
|
||||
header: 'Cluster',
|
||||
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
|
||||
width: { default: 100 },
|
||||
enableSort: false,
|
||||
defaultVisibility: false,
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<TanStackTable.Text>{value as string}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
statefulSetWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sStatefulSetsColumns,
|
||||
getK8sStatefulSetItemKey,
|
||||
getK8sStatefulSetRowKey,
|
||||
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}
|
||||
renderRowData={k8sStatefulSetsRenderRowData}
|
||||
getRowKey={getK8sStatefulSetRowKey}
|
||||
getItemKey={getK8sStatefulSetItemKey}
|
||||
eventCategory={InfraMonitoringEvents.StatefulSet}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,295 +1,239 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { K8sStatefulSetsData } from './api';
|
||||
import { ArrowUpDown } from 'lucide-react';
|
||||
|
||||
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 = (
|
||||
export function getK8sStatefulSetRowKey(
|
||||
statefulSet: K8sStatefulSetsData,
|
||||
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),
|
||||
});
|
||||
): 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
volumeWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
k8sVolumesColumns,
|
||||
getK8sVolumeItemKey,
|
||||
getK8sVolumeRowKey,
|
||||
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}
|
||||
renderRowData={k8sVolumesRenderRowData}
|
||||
getRowKey={getK8sVolumeRowKey}
|
||||
getItemKey={getK8sVolumeItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Volumes}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,164 +1,131 @@
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Group } from 'lucide-react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
|
||||
|
||||
import { K8sRenderedRowData } from '../Base/types';
|
||||
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
|
||||
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
|
||||
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { ValidateColumnValueWrapper } from '../components';
|
||||
import { K8sVolumesData } from './api';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
|
||||
import styles from './table.module.scss';
|
||||
export function getK8sVolumeRowKey(volume: K8sVolumesData): string {
|
||||
return (
|
||||
volume.persistentVolumeClaimName ||
|
||||
volume.meta.k8s_persistentvolumeclaim_name ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export const k8sVolumesColumns: IEntityColumn[] = [
|
||||
export function getK8sVolumeItemKey(volume: K8sVolumesData): string {
|
||||
return volume.persistentVolumeClaimName;
|
||||
}
|
||||
|
||||
export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
|
||||
{
|
||||
label: 'Volume Group',
|
||||
value: 'volumeGroup',
|
||||
id: 'volumeGroup',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-collapse',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'PVC Name',
|
||||
value: 'pvcName',
|
||||
id: 'pvcName',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'hidden-on-expand',
|
||||
},
|
||||
{
|
||||
label: 'Namespace Name',
|
||||
value: 'namespaceName',
|
||||
id: 'namespaceName',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Volume Capacity',
|
||||
value: 'capacity',
|
||||
id: 'capacity',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Volume Utilization',
|
||||
value: 'usage',
|
||||
id: 'usage',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
{
|
||||
label: 'Volume Available',
|
||||
value: 'available',
|
||||
id: 'available',
|
||||
canBeHidden: false,
|
||||
defaultVisibility: true,
|
||||
behavior: 'always-visible',
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sVolumesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
|
||||
{
|
||||
title: (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
<Group size={14} /> VOLUME GROUP
|
||||
</div>
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="PVC Name"
|
||||
icon={<HardDrive data-hide-expanded="true" size={14} />}
|
||||
/>
|
||||
),
|
||||
dataIndex: 'volumeGroup',
|
||||
key: 'volumeGroup',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
align: 'left',
|
||||
sorter: false,
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>PVC Name</div>,
|
||||
dataIndex: 'pvcName',
|
||||
key: 'pvcName',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Namespace Name</div>,
|
||||
dataIndex: 'namespaceName',
|
||||
key: 'namespaceName',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
sorter: false,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div>Volume Capacity</div>,
|
||||
dataIndex: 'capacity',
|
||||
key: 'capacity',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
sorter: true,
|
||||
align: 'left',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,21 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
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',
|
||||
},
|
||||
}));
|
||||
import { EntityProgressBar } from '../components';
|
||||
import { EventContents } from '../commonUtils';
|
||||
|
||||
jest.mock('components/ResizeTable', () => ({
|
||||
ResizeTable: ({ className, dataSource }: any): JSX.Element => (
|
||||
<div data-testid="resize-table" className={className}>
|
||||
{JSON.stringify(dataSource)}
|
||||
</div>
|
||||
ResizeTable: ({ dataSource }: { dataSource: unknown }): JSX.Element => (
|
||||
<div data-testid="resize-table">{JSON.stringify(dataSource)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -25,24 +15,22 @@ jest.mock('container/LogDetailedView/FieldRenderer', () => ({
|
||||
}));
|
||||
|
||||
describe('commonUtils', () => {
|
||||
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();
|
||||
it('renders EntityProgressBar with percentage value', () => {
|
||||
render(<EntityProgressBar value={0.5} type="request" />);
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EventContents with the module-scoped table class', () => {
|
||||
it('renders EntityProgressBar with dash for NaN value', () => {
|
||||
render(<EntityProgressBar value={NaN} type="limit" />);
|
||||
expect(screen.getByText('-')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders EventContents with data fields', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
.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);
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Table, Tooltip, Typography } from 'antd';
|
||||
import { Table, Tooltip } 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 {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getInvalidValueTooltipText, InfraMonitoringEntity } from './constants';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import styles from './commonUtils.module.scss';
|
||||
|
||||
@@ -20,6 +15,10 @@ 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';
|
||||
}
|
||||
@@ -31,36 +30,6 @@ 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
|
||||
*/
|
||||
@@ -103,35 +72,6 @@ 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,
|
||||
}: {
|
||||
@@ -248,19 +188,3 @@ 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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.entityProgressBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 0;
|
||||
min-width: 100px;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
Reference in New Issue
Block a user